Source code for aristopy.solar

#!/usr/bin/env python
# -*- coding: utf-8 -*-
# ==============================================================================
#    S O L A R
# ==============================================================================
"""
* File name: solar.py
* Last edited: 2020-06-30
* Created by: Stefan Bruche (TU Berlin)

The solar classes SolarData, SolarThermalCollector, and PVSystem provide
functionality to model feed-in time series data for solar components (thermal
and electrical) at a certain location and with specific tilt and azimuth angles.

.. note::
   The solar classes require the availability of the Python module *pvlib*.
   The module is not provided with the standard installation of *aristopy*.
   If you want to use the solar classes, consider installing the module in your
   current environment, e.g. via: ::

   >> pip install pvlib

   For further information and an installation guide, users are referred to the
   `pvlib documentation <https://pvlib-python.readthedocs.io/en/stable/>`_.
"""
import inspect
import pandas as pd
try:
    import pvlib
    HAS_PVLIB = True
except ImportError:
    HAS_PVLIB = False


[docs]class SolarData: def __init__(self, ghi, dhi, latitude, longitude, altitude=0, dni=None): """ Class to provide solar input data for PV or solar thermal calculations. The main output is accessed via function "get_plane_of_array_irradiance" for the POA data with specified surface tilt and azimuth values and "get_irradiance_dataframe" for a pandas DataFrame consisting of GHI, DHI and DNI values at the specified location and time index. :param ghi: Pandas series (with datetime index and time zone) for global horizontal irradiation data at the respective location. :param dhi: Pandas series (with datetime index and time zone) for diffuse horizontal irradiation data at the respective location. :param latitude: Latitude value (float, int) for respective location. :param longitude: Longitude value (float, int) for respective location. :param altitude: Altitude value (float, int) for respective location. :param dni: Pandas series (with datetime index and time zone) for direct normal (beam) irradiation data at the respective location. Is calculated from GHI and DHI if not provided here. """ if not HAS_PVLIB: raise ImportError( 'Module "pvlib" not found. Please consider installing it in ' 'your current environment via "pip install pvlib" if you ' 'want to use the solar class "%s".' % self.__class__.__name__) self.solar_position = None # init self.ghi = ghi self.dhi = dhi self.dni = dni self.location = (latitude, longitude, altitude) @property def ghi(self): return self._ghi @ghi.setter def ghi(self, ghi): if not isinstance(ghi, pd.Series): raise TypeError('Input for GHI needs to be a pandas Series') self._ghi = ghi self._data_index = ghi.index self._tz = ghi.index.tz # Update the solar position for the location and the time index # Not during first initialization --> location is inited later if hasattr(self, 'location'): self.solar_position = self.location.get_solarposition( times=self._data_index) @property def data_index(self): return self._data_index @property def dhi(self): return self._dhi @dhi.setter def dhi(self, dhi): if not isinstance(dhi, pd.Series): raise TypeError('Input for DHI needs to be a pandas Series') if not dhi.index.equals(self._data_index): raise ValueError('GHI and DHI need to have the same data index') if dhi.index.tz != self._tz: raise ValueError('GHI and DHI need to have the same time zone') self._dhi = dhi @property def dni(self): return self._dni @dni.setter def dni(self, dni): if isinstance(dni, type(None)): self._dni = None return if not isinstance(dni, pd.Series): raise TypeError('Input for DNI needs to be a pandas Series') if not dni.index.equals(self._data_index): raise ValueError('DNI needs the same data index as GHI and DHI') if dni.index.tz != self._tz: raise ValueError('DNI needs the same time zone as GHI and DHI') self._dni = dni @property def location(self): return self._location @location.setter def location(self, location): if isinstance(location, pvlib.location.Location): self._location = location elif isinstance(location, tuple) and len(location) == 3: self._location = pvlib.location.Location( latitude=location[0], longitude=location[1], altitude=location[2], tz=self._tz) # Update the solar position for the location and the time index self.solar_position = self._location.get_solarposition( times=self.data_index)
[docs] def get_dni(self): # If DNI already specified during initialization of SolarData => use it! if not isinstance(self.dni, type(None)): return self.dni # Else: Calculate DNI in two steps: # 1. Calculate clear sky data for the specified location by using model # 'simplified_solis'. The method returns a DataFrame with ghi, dni, dhi. # The clear sky 'dni' value is used to validate and restrict the dni # returned by the method "pvlib.irradiance.dni". clearsky = self.location.get_clearsky( times=self.solar_position.index, model='simplified_solis', solar_position=self.solar_position) # 2. DNI from GHI, DHI and solar positions. DNI may be unreasonably # high or neg. for zenith angles close to 90° (sunrise/sunset # transitions). Function sets them to NaN => correct to 0 self.dni = pvlib.irradiance.dni( ghi=self.ghi, dhi=self.dhi, zenith=self.solar_position['apparent_zenith'], clearsky_dni=clearsky['dni'], clearsky_tolerance=1.1).fillna(0) return self.dni
[docs] def get_plane_of_array_irradiance(self, surface_tilt, surface_azimuth, **kwargs): """ Calculate and return the plane of array irradiance (POA). :param surface_tilt: tilt of the modules (0=horizontal, 90=vertical) :param surface_azimuth: module azimuth angle (180=facing south) :param kwargs: Option to specify more keyword arguments, e.g, 'albedo' or 'surface_type'. Search for method 'get_total_irradiance' in the documentation of pvlib for further information. :return: pandas DataFrame with POA ('poa_global', ...) """ # Inspect pvlib-function for accepted arguments and filter the kwargs accepted_kwargs = {} kw_options = inspect.signature(pvlib.irradiance.get_total_irradiance) for key, val in kwargs.items(): if key in kw_options.parameters.keys(): accepted_kwargs[key] = val return pvlib.irradiance.get_total_irradiance( surface_tilt=surface_tilt, surface_azimuth=surface_azimuth, dni=self.get_dni(), ghi=self.ghi, dhi=self.dhi, solar_zenith=self.solar_position['apparent_zenith'], solar_azimuth=self.solar_position['azimuth'], **accepted_kwargs)
[docs] def get_irradiance_dataframe(self): """ Create and return a pandas DataFrame consisting of global (GHI) and diffuse horizontal (DHI) and direct normal irradiation (DNI). :return: pandas DataFrame with column names 'ghi', 'dhi', 'dni' """ df = pd.DataFrame(index=self.ghi.index) df['ghi'] = self.ghi df['dhi'] = self.dhi df['dni'] = self.get_dni() return df
[docs]class SolarThermalCollector: def __init__(self, **kwargs): """ Required input arguments (either while creating the class object or while calling function 'get_collector_heat_output'. * 'optical_efficiency': Opt. eff of the collector (float, int) * 'thermal_loss_parameter_1': Th. loss of the collector (float, int) * 'thermal_loss_parameter_2': Th. loss of the collector (float, int) |br| => See equation in: V.Quaschning, 'Regenerative Energiesysteme', 10th edition, Hanser, 2019, p.131ff. * 'irradiance_data': Irradiance (POA) on collector array (pd.Series) * 't_ambient': Ambient temperature (float, int, pd.Series) * 't_collector_in': Collector inlet temperature (float, int, pd.Series) * 't_collector_out': Collector outlet temp. (float, int, pd.Series) """ if not HAS_PVLIB: raise ImportError( 'Module "pvlib" not found. Please consider installing it in ' 'your current environment via "pip install pvlib" if you ' 'want to use the solar class "%s".' % self.__class__.__name__) # initialize private helper variables with None self._all_private_vars = [ '_optical_efficiency', '_thermal_loss_parameter_1', '_thermal_loss_parameter_2', '_t_ambient', '_t_collector_in', '_t_collector_out', '_irradiance_data'] for var in self._all_private_vars: setattr(self, var, None) # Initialize the target values self.t_delta = None self.collector_efficiency = None self.collector_heat = None # Set the keyword arguments for key, val in kwargs.items(): if hasattr(self, key): setattr(self, key, val) @property def t_collector_in(self): return self._t_collector_in @t_collector_in.setter def t_collector_in(self, value): if not isinstance(value, (float, int, pd.Series)): raise ValueError('Expected numeric value or pandas Series.') self._t_collector_in = value self._trigger_calculation() @property def t_collector_out(self): return self._t_collector_out @t_collector_out.setter def t_collector_out(self, value): if not isinstance(value, (float, int, pd.Series)): raise ValueError('Expected numeric value or pandas Series.') self._t_collector_out = value self._trigger_calculation() @property def irradiance_data(self): return self._irradiance_data @irradiance_data.setter def irradiance_data(self, value): if not isinstance(value, pd.Series): raise ValueError('Irradiance data required as pandas Series') self._irradiance_data = value self._trigger_calculation() @property def optical_efficiency(self): return self._optical_efficiency @optical_efficiency.setter def optical_efficiency(self, value): if not isinstance(value, (int, float)) or not 0 < value <= 1: raise ValueError('Expected a value between 0 and 1 for opt. eff.') self._optical_efficiency = value self._trigger_calculation() @property def thermal_loss_parameter_1(self): return self._thermal_loss_parameter_1 @thermal_loss_parameter_1.setter def thermal_loss_parameter_1(self, value): if not isinstance(value, (float, int)): raise ValueError('Expected a numeric value for the th. loss param.') self._thermal_loss_parameter_1 = value self._trigger_calculation() @property def thermal_loss_parameter_2(self): return self._thermal_loss_parameter_2 @thermal_loss_parameter_2.setter def thermal_loss_parameter_2(self, value): if not isinstance(value, (float, int)): raise ValueError('Expected a numeric value for the th. loss param.') self._thermal_loss_parameter_2 = value self._trigger_calculation() @property def t_ambient(self): return self._t_ambient @t_ambient.setter def t_ambient(self, value): if not isinstance(value, (float, int, pd.Series)): raise ValueError('Expected numeric value or pandas Series.') self._t_ambient = value self._trigger_calculation() def _has_all_inputs(self): # flag to check if collector is ready for calculation of heat output has_all_inputs = True for var in self._all_private_vars: if getattr(self, var) is None: has_all_inputs = False return has_all_inputs def _matching_indices(self): # Get all indices and store them in temporary list: indices = [getattr(self, var).index for var in self._all_private_vars if isinstance(getattr(self, var), pd.Series)] # Check that all indices are the same: if len(indices) > 1: for idx in indices[1:]: # starting with second element if not idx.equals(indices[0]): # not equal first element? return False # list of indices has only one element or no return of False return True def _trigger_calculation(self): # calculation of output is triggered whenever a parameter is changed # but only if all required input values are available and indices match. if self._has_all_inputs(): if not self._matching_indices(): raise ValueError('The provided data indices do not match!') else: t_mean = (self.t_collector_in + self.t_collector_out) / 2 self.t_delta = t_mean - self.t_ambient self.collector_efficiency = \ self.optical_efficiency \ - self.thermal_loss_parameter_1 * self.t_delta \ / self.irradiance_data \ - self.thermal_loss_parameter_2 * self.t_delta**2 \ / self.irradiance_data # Replace negative values with zeros self.collector_efficiency[self.collector_efficiency < 0] = 0 self.collector_heat = \ self.irradiance_data * self.collector_efficiency
[docs] def get_collector_heat_output(self, **kwargs): """ Required input arguments (either while creating the class object or while calling function 'get_collector_heat_output'. * 'optical_efficiency': Opt. eff of the collector (float, int) * 'thermal_loss_parameter_1': Th. loss of the collector (float, int) * 'thermal_loss_parameter_2': Th. loss of the collector (float, int) |br| => See equation in: V.Quaschning, 'Regenerative Energiesysteme', 10th edition, Hanser, 2019, p.131ff. * 'irradiance_data': Irradiance (POA) on collector array (pd.Series) * 't_ambient': Ambient temperature (float, int, pd.Series) * 't_collector_in': Collector inlet temperature (float, int, pd.Series) * 't_collector_out': Collector outlet temp. (float, int, pd.Series) :return: pandas Series of provided collector heat output """ # Set the keyword arguments. # The calculation is performed while setting input arguments. for key, val in kwargs.items(): if hasattr(self, key): setattr(self, key, val) # Print a hint which required variables are still missing (if any) for var in self._all_private_vars: if getattr(self, var) is None: print('Missing input parameter "%s" detected' % var[1:]) # Export the result return self.collector_heat
[docs]class PVSystem: def __init__(self, module, inverter): """ PVSystem class holds a type of PV module and PV inverter. The main class function is "get_feedin", to get the power feedin for a PV plant with specified module, inverter, tilt and azimuth angle, location and weather data. :param module: Name of the module as in PVLib Database. To see the full database type e.g. "pvlib.pvsystem.retrieve_sam(name='cecmod')" :param inverter: Name of the inverter as in PVLib Database. To see the database type e.g. "pvlib.pvsystem.retrieve_sam(name='cecinverter')" """ if not HAS_PVLIB: raise ImportError( 'Module "pvlib" not found. Please consider installing it in ' 'your current environment via "pip install pvlib" if you ' 'want to use the solar class "%s".' % self.__class__.__name__) self.system = pvlib.pvsystem.PVSystem() # create empty PVSystem self.module = module self.inverter = inverter # Initialization of default values and (private) attributes self.mode = 'ac' self._location = None self.mc = None # ModelChain @property def module(self): return self._module @module.setter def module(self, module): if module in pvlib.pvsystem.retrieve_sam(name='cecmod'): self._module_parameters = pvlib.pvsystem.retrieve_sam( name='cecmod')[module] elif module in pvlib.pvsystem.retrieve_sam(name='sandiamod'): self._module_parameters = pvlib.pvsystem.retrieve_sam( name='sandiamod')[module] else: raise ValueError('Module %s not found in the database.' % module) # If not raised, do ... self._module = module self.system.module_parameters = self._module_parameters @property def module_parameters(self): return self._module_parameters @property def inverter(self): return self._inverter @inverter.setter def inverter(self, inverter): if inverter in pvlib.pvsystem.retrieve_sam(name='cecinverter'): self._inverter_parameters = pvlib.pvsystem.retrieve_sam( name='cecinverter')[inverter] elif inverter in pvlib.pvsystem.retrieve_sam(name='sandiainverter'): self._inverter_parameters = pvlib.pvsystem.retrieve_sam( name='sandiainverter')[inverter] elif inverter in pvlib.pvsystem.retrieve_sam(name='adrinverter'): self._inverter_parameters = pvlib.pvsystem.retrieve_sam( name='adrinverter')[inverter] else: raise ValueError('Inverter %s not found in the database' % inverter) # If not raised, do ... self._inverter = inverter self.system.inverter_parameters = self._inverter_parameters @property def inverter_parameters(self): return self._inverter_parameters @property def mode(self): return self._mode @mode.setter def mode(self, mode): if mode.lower() == 'ac' or mode.lower() == 'dc': self._mode = mode.lower() else: raise ValueError("Mode must either be 'ac' or 'dc'") @property def location(self): return self._location @location.setter def location(self, location): if isinstance(location, pvlib.location.Location): self._location = location else: raise ValueError('Please provide a valid PVLib location instance!') @property def area(self): """ Get area of the PV system in :math:`m^2` :return: PV System area """ if 'Area' in self.system.module_parameters.index: area_per_module = self.system.module_parameters.Area elif 'A_c' in self.system.module_parameters.index: area_per_module = self.system.module_parameters.A_c else: return None return (area_per_module * self.system.strings_per_inverter * self.system.modules_per_string) @property def peak_power(self): """ PV system peak power [W] can be limited by the inverter or the modules (minimum). If DC mode is selected the inverter is not considered. :return: Peak power of the PV System """ # I and V at the MPP have different names depending on the database mod_par = self.system.module_parameters i_mpo = mod_par.Impo if hasattr(mod_par, 'Impo') else mod_par.I_mp_ref v_mpo = mod_par.Vmpo if hasattr(mod_par, 'Vmpo') else mod_par.V_mp_ref if self.mode == "ac": return min( i_mpo * v_mpo * self.system.strings_per_inverter * self.system.modules_per_string, self.system.inverter_parameters.Paco) else: # self.mode == "dc": return (i_mpo * v_mpo * self.system.strings_per_inverter * self.system.modules_per_string)
[docs] def set_location(self, latitude, longitude, altitude): self.location = pvlib.location.Location( latitude=latitude, longitude=longitude, altitude=altitude)
[docs] def get_feedin(self, weather, surface_tilt, surface_azimuth, scaling=None, mode='ac', **kwargs): """ :param weather: requires pandas DataFrame with at least two out of three column names: 'ghi', 'dhi', 'dni'. Additionally users can specify column names 'wind_speed' and 'temp_air' (used in calc. of losses) :param surface_tilt: tilt of the PV modules (0=horizontal, 90=vertical) :param surface_azimuth: module azimuth angle (180=facing south) :param scaling: a) None=no feed-in scaling [W], b) 'area'=scale feed-in to area [W/m2], c) 'peak_power'=scale feed-in to nominal power [-] :param mode: a) 'ac': return AC feed-in (including inverter), b) 'dc': return DC feed-in (excluding inverter) :param kwargs: Examples for kwargs are 'albedo', 'modules_per_string', 'strings_per_inverter', 'temperature_model_parameters', ... :return: pandas DataFrame with POA ('poa_global', ...) """ if 'location' in kwargs.keys(): self.location = kwargs['location'] self.mode = mode self.system.surface_tilt = surface_tilt self.system.surface_azimuth = surface_azimuth # Construct a ModelChain instance try: self.mc = pvlib.modelchain.ModelChain(self.system, self.location) except ValueError: # If parameters can not be found in module_parameters, try: self.mc = pvlib.modelchain.ModelChain(self.system, self.location, aoi_model='no_loss', spectral_model='no_loss') # Set additional keyword arguments to PV System and ModelChain for key, val in kwargs.items(): if key in dir(self.system): setattr(self.system, key, val) elif key in dir(self.mc): setattr(self.mc, key, val) # Check that ghi, dhi, dni are available or at least 2 out of 3 has_ghi = 1 if 'ghi' in [i.lower() for i in weather] else 0 has_dhi = 1 if 'dhi' in [i.lower() for i in weather] else 0 has_dni = 1 if 'dni' in [i.lower() for i in weather] else 0 if has_ghi + has_dhi + has_dni < 2: raise ValueError('Need at least 2 out of 3 from GHI, DHI, DNI ' 'provided in weather Dataframe.') elif has_ghi + has_dhi + has_dni == 2: self.mc.complete_irradiance(weather=weather) # Might need pytables? # run the model chain self.mc.run_model(weather=weather) # Get feed-in power in AC or DC, scale it to area or peak_power if # required and export result: feedin = self.mc.ac if self.mode == 'ac' else self.mc.dc.p_mp # Replace negative values with zeros feedin[feedin < 0] = 0 if scaling is None: return feedin elif isinstance(scaling, str) and scaling.lower() == 'area': return feedin / float(self.area) elif isinstance(scaling, str) and scaling.lower() == 'peak_power': return feedin / float(self.peak_power) else: raise ValueError('Valid input for parameters "scaling" are None, ' '"area" or "peak_power"')
if __name__ == '__main__': # Set time index (incl. time zone) and sample data for global (GHI) and # diffuse horizontal irradiation (DHI) [W/m²]: idx = pd.date_range(start='2018-01-01 12:00', periods=12, freq='M', tz='UTC') ghi = pd.Series([0, 50, 150, 400, 600, 900, 1000, 600, 400, 150, 50, 0], idx) dhi = pd.Series([0, 30, 100, 200, 300, 400, 0, 100, 50, 10, 0, 0], idx) # SolarData: # ---------- solar = SolarData(ghi=ghi, dhi=dhi, latitude=52.3822, longitude=13.0622, altitude=81) # Solar thermal collector: # ------------------------ poa = solar.get_plane_of_array_irradiance( surface_tilt=45, surface_azimuth=180) solar_coll = SolarThermalCollector( optical_efficiency=0.73, thermal_loss_parameter_1=1.7, thermal_loss_parameter_2=0.016, irradiance_data=poa['poa_global'], t_ambient=20, t_collector_in=20, t_collector_out=40) heat_out = solar_coll.get_collector_heat_output() # PV-System: # ---------- # If available: append wind and temperature data to the DataFrame here weather = solar.get_irradiance_dataframe() # Create a PV System (consisting of a module and an inverter) pv_sys = PVSystem(module='Canadian_Solar_CS5P_220M___2009_', inverter='ABB__MICRO_0_25_I_OUTD_US_208__208V_') # Calculate the feed-in of the PV system for specified weather conditions at # a site and a collector position. temp = pvlib.temperature.TEMPERATURE_MODEL_PARAMETERS['sapm'][ 'open_rack_glass_glass'] feedin = pv_sys.get_feedin(weather=weather, location=solar.location, surface_tilt=25, surface_azimuth=180, temperature_model_parameters=temp)