diff --git a/docs/sphinx/source/api.rst b/docs/sphinx/source/api.rst index e556f06d0a..e99a8411b4 100644 --- a/docs/sphinx/source/api.rst +++ b/docs/sphinx/source/api.rst @@ -547,14 +547,25 @@ Creating a ModelChain object. Running ------- -Running a ModelChain. +A ModelChain can be run from a number of starting points, depending on the +input data available. .. autosummary:: :toctree: generated/ modelchain.ModelChain.run_model + modelchain.ModelChain.run_model_from_poa + modelchain.ModelChain.run_model_from_effective_irradiance + +Functions to assist with setting up ModelChains to run + +.. autosummary:: + :toctree: generated/ + modelchain.ModelChain.complete_irradiance modelchain.ModelChain.prepare_inputs + modelchain.ModelChain.prepare_inputs_from_poa + Attributes ---------- diff --git a/docs/sphinx/source/whatsnew/v0.8.0.rst b/docs/sphinx/source/whatsnew/v0.8.0.rst index 589afe00fb..e3a013562b 100644 --- a/docs/sphinx/source/whatsnew/v0.8.0.rst +++ b/docs/sphinx/source/whatsnew/v0.8.0.rst @@ -105,6 +105,11 @@ Enhancements * Add :py:func:`pvlib.pvsystem.combine_loss_factors` as general purpose function to combine loss factors with a common index. Partialy addresses :issue:`988`. Contributed by Brock Taute :ghuser:`btaute` +* Add capability to run a ModelChain starting with plane-of-array or effective + irradiance, or with back-of-module or cell temperature data. New methods are + :py:meth:`pvlib.modelchain.ModelChain.run_model_from_poa`, + :py:meth:`pvlib.modelchain.ModelChain.run_model_from_effective_irradiance`, + and :py:meth:`pvlib.modelchain.ModelChain.prepare_inputs_from_poa` (:issue:`536`, :pull:`943`) Bug fixes ~~~~~~~~~ diff --git a/pvlib/modelchain.py b/pvlib/modelchain.py index 9e6e3f0fcd..1f362dcfa8 100644 --- a/pvlib/modelchain.py +++ b/pvlib/modelchain.py @@ -18,6 +18,23 @@ from pvlib._deprecation import pvlibDeprecationWarning from pvlib.tools import _build_kwargs +# keys that are used to detect input data and assign data to appropriate +# ModelChain attribute +# for ModelChain.weather +WEATHER_KEYS = set(['ghi', 'dhi', 'dni', 'wind_speed', 'temp_air', + 'precipitable_water']) + +# for ModelChain.total_irrad +POA_DATA_KEYS = set(['poa_global', 'poa_direct', 'poa_diffuse']) + +# Optional keys to communicate temperature data. If provided, +# 'cell_temperature' overrides ModelChain.temperature_model and sets +# ModelChain.cell_temperature to the data. If 'module_temperature' is provdied, +# overrides ModelChain.temperature_model with +# pvlib.temperature.sapm_celL_from_module +TEMPERATURE_KEYS = set(['module_temperature', 'cell_temperature']) + +DATA_KEYS = WEATHER_KEYS | POA_DATA_KEYS | TEMPERATURE_KEYS # these dictionaries contain the default configuration for following # established modeling sequences. They can be used in combination with @@ -959,8 +976,8 @@ def complete_irradiance(self, weather): Examples -------- This example does not work until the parameters `my_system`, - `my_location`, `my_datetime` and `my_weather` are not defined - properly but shows the basic idea how this method can be used. + `my_location`, and `my_weather` are defined but shows the basic idea + how this method can be used. >>> from pvlib.modelchain import ModelChain @@ -1005,10 +1022,84 @@ def complete_irradiance(self, weather): return self + def _prep_inputs_solar_pos(self, kwargs={}): + """ + Assign solar position + """ + self.solar_position = self.location.get_solarposition( + self.weather.index, method=self.solar_position_method, + **kwargs) + return self + + def _prep_inputs_airmass(self): + """ + Assign airmass + """ + self.airmass = self.location.get_airmass( + solar_position=self.solar_position, model=self.airmass_model) + return self + + def _prep_inputs_tracking(self): + """ + Calculate tracker position and AOI + """ + self.tracking = self.system.singleaxis( + self.solar_position['apparent_zenith'], + self.solar_position['azimuth']) + self.tracking['surface_tilt'] = ( + self.tracking['surface_tilt'] + .fillna(self.system.axis_tilt)) + self.tracking['surface_azimuth'] = ( + self.tracking['surface_azimuth'] + .fillna(self.system.axis_azimuth)) + self.aoi = self.tracking['aoi'] + return self + + def _prep_inputs_fixed(self): + """ + Calculate AOI for fixed tilt system + """ + self.aoi = self.system.get_aoi(self.solar_position['apparent_zenith'], + self.solar_position['azimuth']) + return self + + def _verify_df(self, data, required): + """ Checks data for column names in required + + Parameters + ---------- + data : Dataframe + required : List of str + + Raises + ------ + ValueError if any of required are not in data.columns. + """ + if not set(required) <= set(data.columns): + raise ValueError( + "Incomplete input data. Data needs to contain {0}. " + "Detected data contains: {1}".format(required, + list(data.columns))) + return + + def _assign_weather(self, data): + key_list = [k for k in WEATHER_KEYS if k in data] + self.weather = data[key_list].copy() + if self.weather.get('wind_speed') is None: + self.weather['wind_speed'] = 0 + if self.weather.get('temp_air') is None: + self.weather['temp_air'] = 20 + return self + + def _assign_total_irrad(self, data): + key_list = [k for k in POA_DATA_KEYS if k in data] + self.total_irrad = data[key_list].copy() + return self + def prepare_inputs(self, weather): """ Prepare the solar position, irradiance, and weather inputs to - the model. + the model, starting with GHI, DNI and DHI. Parameters ---------- @@ -1020,7 +1111,7 @@ def prepare_inputs(self, weather): Notes ----- - Assigns attributes: ``solar_position``, ``airmass``, + Assigns attributes: ``weather``, ``solar_position``, ``airmass``, ``total_irrad``, ``aoi`` See also @@ -1028,43 +1119,27 @@ def prepare_inputs(self, weather): ModelChain.complete_irradiance """ - if not {'ghi', 'dni', 'dhi'} <= set(weather.columns): - raise ValueError( - "Uncompleted irradiance data set. Please check your input " - "data.\nData set needs to have 'dni', 'dhi' and 'ghi'.\n" - "Detected data: {0}".format(list(weather.columns))) - - self.weather = weather + self._verify_df(weather, required=['ghi', 'dni', 'ghi']) + self._assign_weather(weather) self.times = self.weather.index + + # build kwargs for solar position calculation try: - kwargs = _build_kwargs(['pressure', 'temp_air'], weather) - kwargs['temperature'] = kwargs.pop('temp_air') + press_temp = _build_kwargs(['pressure', 'temp_air'], weather) + press_temp['temperature'] = press_temp.pop('temp_air') except KeyError: pass - self.solar_position = self.location.get_solarposition( - self.weather.index, method=self.solar_position_method, - **kwargs) - - self.airmass = self.location.get_airmass( - solar_position=self.solar_position, model=self.airmass_model) + self._prep_inputs_solar_pos(press_temp) + self._prep_inputs_airmass() # PVSystem.get_irradiance and SingleAxisTracker.get_irradiance # and PVSystem.get_aoi and SingleAxisTracker.get_aoi # have different method signatures. Use partial to handle # the differences. if isinstance(self.system, SingleAxisTracker): - self.tracking = self.system.singleaxis( - self.solar_position['apparent_zenith'], - self.solar_position['azimuth']) - self.tracking['surface_tilt'] = ( - self.tracking['surface_tilt'] - .fillna(self.system.axis_tilt)) - self.tracking['surface_azimuth'] = ( - self.tracking['surface_azimuth'] - .fillna(self.system.axis_azimuth)) - self.aoi = self.tracking['aoi'] + self._prep_inputs_tracking() get_irradiance = partial( self.system.get_irradiance, self.tracking['surface_tilt'], @@ -1072,9 +1147,7 @@ def prepare_inputs(self, weather): self.solar_position['apparent_zenith'], self.solar_position['azimuth']) else: - self.aoi = self.system.get_aoi( - self.solar_position['apparent_zenith'], - self.solar_position['azimuth']) + self._prep_inputs_fixed() get_irradiance = partial( self.system.get_irradiance, self.solar_position['apparent_zenith'], @@ -1087,41 +1160,246 @@ def prepare_inputs(self, weather): airmass=self.airmass['airmass_relative'], model=self.transposition_model) - if self.weather.get('wind_speed') is None: - self.weather['wind_speed'] = 0 - if self.weather.get('temp_air') is None: - self.weather['temp_air'] = 20 + return self + + def prepare_inputs_from_poa(self, data): + """ + Prepare the solar position, irradiance and weather inputs to + the model, starting with plane-of-array irradiance. + + Parameters + ---------- + data : DataFrame + Contains plane-of-array irradiance data. Required column names + include ``'poa_global'``, ``'poa_direct'`` and ``'poa_diffuse'``. + Columns with weather-related data are ssigned to the + ``weather`` attribute. If columns for ``'temp_air'`` and + ``'wind_speed'`` are not provided, air temperature of 20 C and wind + speed of 0 m/s are assumed. + + Notes + ----- + Assigns attributes: ``weather``, ``total_irrad``, ``solar_position``, + ``airmass``, ``aoi``. + + See also + -------- + pvlib.modelchain.ModelChain.prepare_inputs + """ + + self._assign_weather(data) + + self._verify_df(data, required=['poa_global', 'poa_direct', + 'poa_diffuse']) + self._assign_total_irrad(data) + + self._prep_inputs_solar_pos() + self._prep_inputs_airmass() + + if isinstance(self.system, SingleAxisTracker): + self._prep_inputs_tracking() + else: + self._prep_inputs_fixed() + + return self + + def _prepare_temperature(self, data=None): + """ + Sets cell_temperature using inputs in data and the specified + temperature model. + + If 'data' contains 'cell_temperature', these values are assigned to + attribute ``cell_temperature``. If 'data' contains 'module_temperature` + and `temperature_model' is 'sapm', cell temperature is calculated using + :py:func:`pvlib.temperature.sapm_celL_from_module`. Otherwise, cell + temperature is calculated by 'temperature_model'. + + Parameters + ---------- + data : DataFrame, default None + May contain columns ``'cell_temperature'`` or + ``'module_temperaure'``. + + Returns + ------- + self + + Assigns attribute ``cell_temperature``. + + """ + if 'cell_temperature' in data: + self.cell_temperature = data['cell_temperature'] + return self + + # cell_temperature is not in input. Calculate cell_temperature using + # a temperature_model. + # If module_temperature is in input data we can use the SAPM cell + # temperature model. + if (('module_temperature' in data) and + (self.temperature_model.__name__ == 'sapm_temp')): + # use SAPM cell temperature model only + self.cell_temperature = pvlib.temperature.sapm_cell_from_module( + module_temperature=data['module_temperature'], + poa_global=self.total_irrad['poa_global'], + deltaT=self.system.temperature_model_parameters['deltaT']) + return self + + # Calculate cell temperature from weather data. Cell temperature models + # expect total_irrad['poa_global']. + self.temperature_model() return self def run_model(self, weather): """ - Run the model. + Run the model chain starting with broadband global, diffuse and/or + direct irradiance. Parameters ---------- weather : DataFrame - Column names must be ``'dni'``, ``'ghi'``, ``'dhi'``, - ``'wind_speed'``, ``'temp_air'``. All irradiance components - are required. Air temperature of 20 C and wind speed - of 0 m/s will be added to the DataFrame if not provided. + Irradiance column names must include ``'dni'``, ``'ghi'``, and + ``'dhi'``. If optional columns ``'temp_air'`` and ``'wind_speed'`` + are not provided, air temperature of 20 C and wind speed of 0 m/s + are added to the DataFrame. If optional column + ``'cell_temperature'`` is provided, these values are used instead + of `temperature_model`. If optional column `module_temperature` + is provided, `temperature_model` must be ``'sapm'``. Returns ------- self - Assigns attributes: ``solar_position``, ``airmass``, ``irradiance``, - ``total_irrad``, ``effective_irradiance``, ``weather``, - ``cell_temperature``, ``aoi``, ``aoi_modifier``, ``spectral_modifier``, - ``dc``, ``ac``, ``losses``, - ``diode_params`` (if dc_model is a single diode model) + Notes + ----- + Assigns attributes: ``solar_position``, ``airmass``, ``weather``, + ``total_irrad``, ``aoi``, ``aoi_modifier``, ``spectral_modifier``, + and ``effective_irradiance``, ``cell_temperature``, ``dc``, ``ac``, + ``losses``, ``diode_params`` (if dc_model is a single diode model). + + See also + -------- + pvlib.modelchain.ModelChain.run_model_from_poa + pvlib.modelchain.ModelChain.run_model_from_effective_irradiance """ self.prepare_inputs(weather) self.aoi_model() self.spectral_model() self.effective_irradiance_model() - self.temperature_model() + + self._run_from_effective_irrad(weather) + + return self + + def run_model_from_poa(self, data): + """ + Run the model starting with broadband irradiance in the plane of array. + + Data must include direct, diffuse and total irradiance (W/m2) in the + plane of array. Reflections and spectral adjustments are made to + calculate effective irradiance (W/m2). + + Parameters + ---------- + data : DataFrame + Required column names include ``'poa_global'``, + ``'poa_direct'`` and ``'poa_diffuse'``. If optional columns + ``'temp_air'`` and ``'wind_speed'`` are not provided, air + temperature of 20 C and wind speed of 0 m/s are assumed. + If optional column ``'cell_temperature'`` is provided, these values + are used instead of `temperature_model`. If optional column + ``'module_temperature'`` is provided, `temperature_model` must be + ``'sapm'``. + + Returns + ------- + self + + Notes + ----- + Assigns attributes: ``solar_position``, ``airmass``, ``weather``, + ``total_irrad``, ``aoi``, ``aoi_modifier``, ``spectral_modifier``, + and ``effective_irradiance``, ``cell_temperature``, ``dc``, ``ac``, + ``losses``, ``diode_params`` (if dc_model is a single diode model). + + See also + -------- + pvlib.modelchain.ModelChain.run_model + pvlib.modelchain.ModelChain.run_model_from_effective_irradiance + """ + + self.prepare_inputs_from_poa(data) + + self.aoi_model() + self.spectral_model() + self.effective_irradiance_model() + + self._run_from_effective_irrad(data) + + return self + + def _run_from_effective_irrad(self, data=None): + """ + Executes the temperature, DC, losses and AC models. + + Parameters + ---------- + data : DataFrame, default None + If optional column ``'cell_temperature'`` is provided, these values + are used instead of `temperature_model`. If optional column + `module_temperature` is provided, `temperature_model` must be + ``'sapm'``. + + Returns + ------- + self + + Notes + ----- + Assigns attributes:``cell_temperature``, ``dc``, ``ac``, ``losses``, + ``diode_params`` (if dc_model is a single diode model). + """ + self._prepare_temperature(data) self.dc_model() self.losses_model() self.ac_model() return self + + def run_model_from_effective_irradiance(self, data=None): + """ + Run the model starting with effective irradiance in the plane of array. + + Effective irradiance is irradiance in the plane-of-array after any + adjustments for soiling, reflections and spectrum. + + Parameters + ---------- + data : DataFrame, default None + Required column is ``'effective_irradiance'``. + If optional column ``'cell_temperature'`` is provided, these values + are used instead of `temperature_model`. If optional column + ``'module_temperature'`` is provided, `temperature_model` must be + ``'sapm'``. + + Returns + ------- + self + + Notes + ----- + Assigns attributes: ``weather``, ``total_irrad``, + ``effective_irradiance``, ``cell_temperature``, ``dc``, ``ac``, + ``losses``, ``diode_params`` (if dc_model is a single diode model). + + See also + -------- + pvlib.modelchain.ModelChain.run_model_from + pvlib.modelchain.ModelChain.run_model_from_poa + """ + + self._assign_weather(data) + self._assign_total_irrad(data) + self.effective_irradiance = data['effective_irradiance'] + self._run_from_effective_irrad(data) + + return self diff --git a/pvlib/tests/test_modelchain.py b/pvlib/tests/test_modelchain.py index af7d631dc9..0869beeb8b 100644 --- a/pvlib/tests/test_modelchain.py +++ b/pvlib/tests/test_modelchain.py @@ -10,7 +10,7 @@ from pvlib.location import Location from pvlib._deprecation import pvlibDeprecationWarning -from conftest import assert_series_equal +from conftest import assert_series_equal, assert_frame_equal import pytest from conftest import fail_on_pvlib_version, requires_scipy @@ -178,6 +178,13 @@ def weather(): return weather +@pytest.fixture +def total_irrad(weather): + return pd.DataFrame({'poa_global': [800., 500.], + 'poa_diffuse': [300., 200.], + 'poa_direct': [500., 300.]}, index=weather.index) + + def test_ModelChain_creation(sapm_dc_snl_ac_system, location): ModelChain(sapm_dc_snl_ac_system, location) @@ -327,6 +334,88 @@ def test_run_model_tracker(sapm_dc_snl_ac_system, location, weather, mocker): assert np.isnan(mc.ac[1]) +def test__assign_total_irrad(sapm_dc_snl_ac_system, location, weather, + total_irrad): + weather[['poa_global', 'poa_diffuse', 'poa_direct']] = total_irrad + mc = ModelChain(sapm_dc_snl_ac_system, location) + mc._assign_total_irrad(weather) + for k in modelchain.POA_DATA_KEYS: + assert_series_equal(mc.total_irrad[k], total_irrad[k]) + + +def test_prepare_inputs_from_poa(sapm_dc_snl_ac_system, location, + weather, total_irrad): + data = weather.copy() + data[['poa_global', 'poa_diffuse', 'poa_direct']] = total_irrad + mc = ModelChain(sapm_dc_snl_ac_system, location) + mc.prepare_inputs_from_poa(data) + # weather attribute + assert_frame_equal(mc.weather, weather) + # total_irrad attribute + assert_frame_equal(mc.total_irrad, total_irrad) + + +def test__prepare_temperature(sapm_dc_snl_ac_system, location, weather, + total_irrad): + data = weather.copy() + data[['poa_global', 'poa_diffuse', 'poa_direct']] = total_irrad + mc = ModelChain(sapm_dc_snl_ac_system, location, aoi_model='no_loss', + spectral_model='no_loss') + # prepare_temperature expects mc.total_irrad and mc.weather to be set + mc._assign_weather(data) + mc._assign_total_irrad(data) + mc._prepare_temperature(data) + expected = pd.Series([48.928025, 38.080016], index=data.index) + assert_series_equal(mc.cell_temperature, expected) + data['module_temperature'] = [40., 30.] + mc._prepare_temperature(data) + expected = pd.Series([42.4, 31.5], index=data.index) + assert_series_equal(mc.cell_temperature, expected) + data['cell_temperature'] = [50., 35.] + mc._prepare_temperature(data) + assert_series_equal(mc.cell_temperature, data['cell_temperature']) + + +def test_run_model_from_poa(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).ac + expected = pd.Series(np.array([149.280238, 96.678385]), + index=total_irrad.index) + assert_series_equal(ac, expected) + + +def test_run_model_from_poa_tracking(sapm_dc_snl_ac_system, location, + total_irrad): + system = SingleAxisTracker( + module_parameters=sapm_dc_snl_ac_system.module_parameters, + temperature_model_parameters=( + sapm_dc_snl_ac_system.temperature_model_parameters + ), + inverter_parameters=sapm_dc_snl_ac_system.inverter_parameters) + mc = ModelChain(system, location, aoi_model='no_loss', + spectral_model='no_loss') + ac = mc.run_model_from_poa(total_irrad).ac + assert (mc.tracking.columns == ['tracker_theta', 'aoi', 'surface_azimuth', + 'surface_tilt']).all() + expected = pd.Series(np.array([149.280238, 96.678385]), + index=total_irrad.index) + assert_series_equal(ac, expected) + + +def test_run_model_from_effective_irradiance(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).ac + expected = pd.Series(np.array([149.280238, 96.678385]), + index=data.index) + assert_series_equal(ac, expected) + + def poadc(mc): mc.dc = mc.total_irrad['poa_global'] * 0.2 mc.dc.name = None # assert_series_equal will fail without this