import os
import logging
import pandas as pd
from math import acos, tan
if not 'READTHEDOCS' in os.environ:
from shapely.geometry import LineString
from .grids import LVGrid, MVGrid
logger = logging.getLogger('edisgo')
[docs]class Component:
"""Generic component
Notes
-----
In case of a MV-LV voltage station, :attr:`grid` refers to the LV grid.
"""
def __init__(self, **kwargs):
self._id = kwargs.get('id', None)
self._geom = kwargs.get('geom', None)
self._grid = kwargs.get('grid', None)
@property
def id(self):
"""
Unique ID of component
Returns
--------
:obj:`int`
Unique ID of component
"""
return self._id
@id.setter
def id(self, id):
self._id = id
@property
def geom(self):
"""
Location of component
Returns
--------
:shapely:`Shapely Point object<points>` or :shapely:`Shapely LineString object<linestrings>`
Location of the :class:`Component` as Shapely Point or LineString
"""
return self._geom
@geom.setter
def geom(self, geom):
self._geom = geom
@property
def grid(self):
"""
Grid the component belongs to
Returns
--------
:class:`~.grid.grids.MVGrid` or :class:`~.grid.grids.LVGrid`
The MV or LV grid the component belongs to
"""
return self._grid
@grid.setter
def grid(self, grid):
self._grid = grid
def __repr__(self):
return '_'.join([self.__class__.__name__, str(self._id)])
[docs]class Station(Component):
"""Station object (medium or low voltage)
Represents a station, contains transformers.
Attributes
----------
"""
def __init__(self, **kwargs):
super().__init__(**kwargs)
self._transformers = kwargs.get('transformers', None)
@property
def transformers(self):
""":obj:`list` of :class:`Transformer` : Transformers located in
station"""
return self._transformers
@transformers.setter
def transformers(self, transformer):
"""
Parameters
----------
transformer : :obj:`list` of :class:`Transformer`
"""
self._transformers = transformer
[docs]class Load(Component):
"""
Load object
Attributes
----------
_timeseries : :pandas:`pandas.Series<series>`, optional
See `timeseries` getter for more information.
_consumption : :obj:`dict`, optional
See `consumption` getter for more information.
_timeseries_reactive : :pandas:`pandas.Series<series>`, optional
See `timeseries_reactive` getter for more information.
_power_factor : :obj:`float`, optional
See `power_factor` getter for more information.
_reactive_power_mode : :obj:`str`, optional
See `reactive_power_mode` getter for more information.
_q_sign : :obj:`int`, optional
See `q_sign` getter for more information.
"""
def __init__(self, **kwargs):
super().__init__(**kwargs)
self._timeseries = kwargs.get('timeseries', None)
self._consumption = kwargs.get('consumption', None)
self._timeseries_reactive = kwargs.get('timeseries_reactive', None)
self._power_factor = kwargs.get('power_factor', None)
self._reactive_power_mode = kwargs.get('reactive_power_mode', None)
self._q_sign = None
@property
def timeseries(self):
"""
Load time series
It returns the actual time series used in power flow analysis. If
:attr:`_timeseries` is not :obj:`None`, it is returned. Otherwise,
:meth:`timeseries()` looks for time series of the according sector in
:class:`~.grid.network.TimeSeries` object.
Returns
-------
:pandas:`pandas.DataFrame<dataframe>`
DataFrame containing active power in kW in column 'p' and
reactive power in kVA in column 'q'.
"""
if self._timeseries is None:
if isinstance(self.grid, MVGrid):
voltage_level = 'mv'
elif isinstance(self.grid, LVGrid):
voltage_level = 'lv'
ts_total = None
for sector in self.consumption.keys():
consumption = self.consumption[sector]
# check if load time series for MV and LV are differentiated
try:
ts = self.grid.network.timeseries.load[
sector, voltage_level].to_frame('p')
except KeyError:
try:
ts = self.grid.network.timeseries.load[
sector].to_frame('p')
except KeyError:
logger.exception(
"No timeseries for load of type {} "
"given.".format(sector))
raise
ts = ts * consumption
ts_q = self.timeseries_reactive
if ts_q is not None:
ts['q'] = ts_q.q
else:
ts['q'] = ts['p'] * self.q_sign * tan(
acos(self.power_factor))
if ts_total is None:
ts_total = ts
else:
ts_total.p += ts.p
ts_total.q += ts.q
return ts_total
else:
return self._timeseries
@property
def timeseries_reactive(self):
"""
Reactive power time series in kvar.
Parameters
-----------
timeseries_reactive : :pandas:`pandas.Seriese<series>`
Series containing reactive power in kvar.
Returns
-------
:pandas:`pandas.Series<series>` or None
Series containing reactive power time series in kvar. If it is not
set it is tried to be retrieved from `load_reactive_power`
attribute of global TimeSeries object. If that is not possible
None is returned.
"""
if self._timeseries_reactive is None:
# if normalized reactive power time series are given, they are
# scaled by the annual consumption; if none are given reactive
# power time series are calculated timeseries getter using a given
# power factor
if self.grid.network.timeseries.load_reactive_power is not None:
self.power_factor = 'not_applicable'
self.reactive_power_mode = 'not_applicable'
ts_total = None
for sector in self.consumption.keys():
consumption = self.consumption[sector]
try:
ts = self.grid.network.timeseries.load_reactive_power[
sector].to_frame('q')
except KeyError:
logger.exception(
"No timeseries for load of type {} "
"given.".format(sector))
raise
ts = ts * consumption
if ts_total is None:
ts_total = ts
else:
ts_total.q += ts.q
return ts_total
else:
return None
else:
return self._timeseries_reactive
@timeseries_reactive.setter
def timeseries_reactive(self, timeseries_reactive):
if isinstance(timeseries_reactive, pd.Series):
self._timeseries_reactive = timeseries_reactive
self._power_factor = 'not_applicable'
self._reactive_power_mode = 'not_applicable'
else:
raise ValueError(
"Reactive power time series of load {} needs to be a pandas "
"Series.".format(repr(self)))
[docs] def pypsa_timeseries(self, attr):
"""Return time series in PyPSA format
Parameters
----------
attr : str
Attribute name (PyPSA conventions). Choose from {p_set, q_set}
"""
return self.timeseries[attr] / 1e3
@property
def consumption(self):
""":obj:`dict` : Annual consumption per sector in kWh
Sectors
- retail/industrial
- agricultural
- residential
The format of the :obj:`dict` is as follows::
{
'residential': 453.4
}
"""
return self._consumption
@consumption.setter
def consumption(self, cons_dict):
self._consumption = cons_dict
@property
def peak_load(self):
"""
Get sectoral peak load
"""
peak_load = pd.Series(self.consumption).mul(pd.Series(
self.grid.network.config['peakload_consumption_ratio']).astype(
float), fill_value=0)
return peak_load
@property
def power_factor(self):
"""
Power factor of load
Parameters
-----------
power_factor : :obj:`float`
Ratio of real power to apparent power.
Returns
--------
:obj:`float`
Ratio of real power to apparent power. If power factor is not set
it is retrieved from the network config object depending on the
grid level the load is in.
"""
if self._power_factor is None:
if isinstance(self.grid, MVGrid):
self._power_factor = self.grid.network.config[
'reactive_power_factor']['mv_load']
elif isinstance(self.grid, LVGrid):
self._power_factor = self.grid.network.config[
'reactive_power_factor']['lv_load']
return self._power_factor
@power_factor.setter
def power_factor(self, power_factor):
self._power_factor = power_factor
@property
def reactive_power_mode(self):
"""
Power factor mode of Load.
This information is necessary to make the load behave in an inductive
or capacitive manner. Essentially this changes the sign of the reactive
power.
The convention used here in a load is that:
- when `reactive_power_mode` is 'inductive' then Q is positive
- when `reactive_power_mode` is 'capacitive' then Q is negative
Parameters
----------
reactive_power_mode : :obj:`str` or None
Possible options are 'inductive', 'capacitive' and
'not_applicable'. In the case of 'not_applicable' a reactive
power time series must be given.
Returns
-------
:obj:`str`
In the case that this attribute is not set, it is retrieved from
the network config object depending on the voltage level the load
is in.
"""
if self._reactive_power_mode is None:
if isinstance(self.grid, MVGrid):
self._reactive_power_mode = self.grid.network.config[
'reactive_power_mode']['mv_load']
elif isinstance(self.grid, LVGrid):
self._reactive_power_mode = self.grid.network.config[
'reactive_power_mode']['lv_load']
return self._reactive_power_mode
@reactive_power_mode.setter
def reactive_power_mode(self, reactive_power_mode):
self._reactive_power_mode = reactive_power_mode
@property
def q_sign(self):
"""
Get the sign of reactive power based on :attr:`_reactive_power_mode`.
Returns
-------
:obj:`int` or None
In case of inductive reactive power returns +1 and in case of
capacitive reactive power returns -1. If reactive power time
series is given, `q_sign` is set to None.
"""
if self.reactive_power_mode.lower() == 'inductive':
return 1
elif self.reactive_power_mode.lower() == 'capacitive':
return -1
elif self.reactive_power_mode.lower() == 'not_applicable':
return None
else:
raise ValueError("Unknown value {} in reactive_power_mode for "
"Load {}.".format(self.reactive_power_mode,
repr(self)))
def __repr__(self):
return '_'.join(['Load',
sorted(list(self.consumption.keys()))[0],
repr(self.grid),
str(self.id)])
[docs]class Generator(Component):
"""Generator object
Attributes
----------
_timeseries : :pandas:`pandas.Series<series>`, optional
See `timeseries` getter for more information.
_nominal_capacity : :obj:`dict`, optional
See `nominal_capacity` getter for more information.
_type : :pandas:`pandas.Series<series>`, optional
See `type` getter for more information.
_subtype : :obj:`str`, optional
See `subtype` getter for more information.
_v_level : :obj:`str`, optional
See `v_level` getter for more information.
_q_sign : :obj:`int`, optional
See `q_sign` getter for more information.
_power_factor : :obj:`float`, optional
See `power_factor` getter for more information.
_reactive_power_mode : :obj:`str`, optional
See `reactive_power_mode` getter for more information.
_q_sign : :obj:`int`, optional
See `q_sign` getter for more information.
Notes
-----
The attributes :attr:`_type` and :attr:`_subtype` have to match the
corresponding types in :class:`~.grid.network.Timeseries` to
allow allocation of time series to generators.
See also
--------
edisgo.network.TimeSeries : Details of global
:class:`~.grid.network.TimeSeries`
"""
def __init__(self, **kwargs):
super().__init__(**kwargs)
self._nominal_capacity = kwargs.get('nominal_capacity', None)
self._type = kwargs.get('type', None)
self._subtype = kwargs.get('subtype', None)
self._v_level = kwargs.get('v_level', None)
self._timeseries = kwargs.get('timeseries', None)
self._timeseries_reactive = kwargs.get('timeseries_reactive', None)
self._power_factor = kwargs.get('power_factor', None)
self._reactive_power_mode = kwargs.get('reactive_power_mode', None)
self._q_sign = None
@property
def timeseries(self):
"""
Feed-in time series of generator
It returns the actual dispatch time series used in power flow analysis.
If :attr:`_timeseries` is not :obj:`None`, it is returned. Otherwise,
:meth:`timeseries` looks for time series of the according type of
technology in :class:`~.grid.network.TimeSeries`. If the reactive
power time series is provided through :attr:`_timeseries_reactive`,
this is added to :attr:`_timeseries`. When :attr:`_timeseries_reactive`
is not set, the reactive power is also calculated in
:attr:`_timeseries` using :attr:`power_factor` and
:attr:`reactive_power_mode`. The :attr:`power_factor` determines the
magnitude of the reactive power based on the power factor and active
power provided and the :attr:`reactive_power_mode` determines if the
reactive power is either consumed (inductive behaviour) or provided
(capacitive behaviour).
Returns
-------
:pandas:`pandas.DataFrame<dataframe>`
DataFrame containing active power in kW in column 'p' and
reactive power in kvar in column 'q'.
"""
if self._timeseries is None:
# calculate time series for active and reactive power
try:
timeseries = \
self.grid.network.timeseries.generation_dispatchable[
self.type].to_frame('p')
except KeyError:
try:
timeseries = \
self.grid.network.timeseries.generation_dispatchable[
'other'].to_frame('p')
except KeyError:
logger.exception("No time series for type {} "
"given.".format(self.type))
raise
timeseries = timeseries * self.nominal_capacity
if self.timeseries_reactive is not None:
timeseries['q'] = self.timeseries_reactive
else:
timeseries['q'] = timeseries['p'] * self.q_sign * tan(acos(
self.power_factor))
return timeseries
else:
return self._timeseries.loc[
self.grid.network.timeseries.timeindex, :]
@property
def timeseries_reactive(self):
"""
Reactive power time series in kvar.
Parameters
-----------
timeseries_reactive : :pandas:`pandas.Seriese<series>`
Series containing reactive power in kvar.
Returns
-------
:pandas:`pandas.Series<series>` or None
Series containing reactive power time series in kvar. If it is not
set it is tried to be retrieved from `generation_reactive_power`
attribute of global TimeSeries object. If that is not possible
None is returned.
"""
if self._timeseries_reactive is None:
if self.grid.network.timeseries.generation_reactive_power \
is not None:
try:
timeseries = \
self.grid.network.timeseries.generation_reactive_power[
self.type].to_frame('q')
except (KeyError, TypeError):
try:
timeseries = \
self.grid.network.timeseries.generation_reactive_power[
'other'].to_frame('q')
except:
logger.warning(
"No reactive power time series for type {} given. "
"Reactive power time series will be calculated from "
"assumptions in config files and active power "
"timeseries.".format(self.type))
return None
self.power_factor = 'not_applicable'
self.reactive_power_mode = 'not_applicable'
return timeseries * self.nominal_capacity
else:
return None
else:
return self._timeseries_reactive.loc[
self.grid.network.timeseries.timeindex, :]
@timeseries_reactive.setter
def timeseries_reactive(self, timeseries_reactive):
if isinstance(timeseries_reactive, pd.Series):
# check if the values in time series makes sense
if timeseries_reactive.max() <= self._nominal_capacity:
self._timeseries_reactive = timeseries_reactive
else:
message = "Maximum reactive power in timeseries at index " \
"{} ".format(timeseries_reactive.idxmax()) + \
"is higher than nominal capacity."
logger.error(message)
raise ValueError(message)
else:
raise ValueError(
"Reactive power time series of generator {} needs to be a "
"pandas Series.".format(repr(self)))
[docs] def pypsa_timeseries(self, attr):
"""
Return time series in PyPSA format
Convert from kW, kVA to MW, MVA
Parameters
----------
attr : :obj:`str`
Attribute name (PyPSA conventions). Choose from {p_set, q_set}
"""
return self.timeseries[attr] / 1e3
@property
def type(self):
""":obj:`str` : Technology type (e.g. 'solar')"""
return self._type
@property
def subtype(self):
""":obj:`str` : Technology subtype (e.g. 'solar_roof_mounted')"""
return self._subtype
@property
def nominal_capacity(self):
""":obj:`float` : Nominal generation capacity in kW"""
return self._nominal_capacity
@nominal_capacity.setter
def nominal_capacity(self, nominal_capacity):
self._nominal_capacity = nominal_capacity
@property
def v_level(self):
""":obj:`int` : Voltage level"""
return self._v_level
@property
def power_factor(self):
"""
Power factor of generator
Parameters
-----------
power_factor : :obj:`float`
Ratio of real power to apparent power.
Returns
--------
:obj:`float`
Ratio of real power to apparent power. If power factor is not set
it is retrieved from the network config object depending on the
grid level the generator is in.
"""
if self._power_factor is None:
if isinstance(self.grid, MVGrid):
self._power_factor = self.grid.network.config[
'reactive_power_factor']['mv_gen']
elif isinstance(self.grid, LVGrid):
self._power_factor = self.grid.network.config[
'reactive_power_factor']['lv_gen']
return self._power_factor
@power_factor.setter
def power_factor(self, power_factor):
self._power_factor = power_factor
@property
def reactive_power_mode(self):
"""
Power factor mode of generator.
This information is necessary to make the generator behave in an
inductive or capacitive manner. Essentially this changes the sign of
the reactive power.
The convention used here in a generator is that:
- when `reactive_power_mode` is 'capacitive' then Q is positive
- when `reactive_power_mode` is 'inductive' then Q is negative
In the case that this attribute is not set, it is retrieved from the
network config object depending on the voltage level the generator
is in.
Parameters
----------
reactive_power_mode : :obj:`str` or None
Possible options are 'inductive', 'capacitive' and
'not_applicable'. In the case of 'not_applicable' a reactive
power time series must be given.
Returns
-------
:obj:`str` : Power factor mode
In the case that this attribute is not set, it is retrieved from
the network config object depending on the voltage level the
generator is in.
"""
if self._reactive_power_mode is None:
if isinstance(self.grid, MVGrid):
self._reactive_power_mode = self.grid.network.config[
'reactive_power_mode']['mv_gen']
elif isinstance(self.grid, LVGrid):
self._reactive_power_mode = self.grid.network.config[
'reactive_power_mode']['lv_gen']
return self._reactive_power_mode
@reactive_power_mode.setter
def reactive_power_mode(self, reactive_power_mode):
self._reactive_power_mode = reactive_power_mode
@property
def q_sign(self):
"""
Get the sign of reactive power based on :attr:`_reactive_power_mode`.
Returns
-------
:obj:`int` or None
In case of inductive reactive power returns -1 and in case of
capacitive reactive power returns +1. If reactive power time
series is given, `q_sign` is set to None.
"""
if self.reactive_power_mode.lower() == 'inductive':
return -1
elif self.reactive_power_mode.lower() == 'capacitive':
return 1
else:
raise ValueError("Unknown value {} in reactive_power_mode for "
"Generator {}.".format(self.reactive_power_mode,
repr(self)))
[docs]class GeneratorFluctuating(Generator):
"""
Generator object for fluctuating renewables.
Attributes
----------
_curtailment : :pandas:`pandas.Series<series>`
See `curtailment` getter for more information.
_weather_cell_id : :obj:`int`
See `weather_cell_id` getter for more information.
"""
def __init__(self, **kwargs):
super().__init__(**kwargs)
self._curtailment = kwargs.get('curtailment', None)
self._weather_cell_id = kwargs.get('weather_cell_id', None)
@property
def timeseries(self):
"""
Feed-in time series of generator
It returns the actual time series used in power flow analysis. If
:attr:`_timeseries` is not :obj:`None`, it is returned. Otherwise,
:meth:`timeseries` looks for generation and curtailment time series
of the according type of technology (and weather cell) in
:class:`~.grid.network.TimeSeries`.
Returns
-------
:pandas:`pandas.DataFrame<dataframe>`
DataFrame containing active power in kW in column 'p' and
reactive power in kVA in column 'q'.
"""
if self._timeseries is None:
# get time series for active power depending on if they are
# differentiated by weather cell ID or not
if isinstance(self.grid.network.timeseries.generation_fluctuating.
columns, pd.MultiIndex):
if self.weather_cell_id:
try:
timeseries = self.grid.network.timeseries.\
generation_fluctuating[
self.type, self.weather_cell_id].to_frame('p')
except KeyError:
logger.exception("No time series for type {} and "
"weather cell ID {} given.".format(
self.type, self.weather_cell_id))
raise
else:
logger.exception("No weather cell ID provided for "
"fluctuating generator {}.".format(
repr(self)))
raise KeyError
else:
try:
timeseries = self.grid.network.timeseries.\
generation_fluctuating[self.type].to_frame('p')
except KeyError:
logger.exception("No time series for type {} "
"given.".format(self.type))
raise
timeseries = timeseries * self.nominal_capacity
# subtract curtailment
if self.curtailment is not None:
timeseries = timeseries.join(
self.curtailment.to_frame('curtailment'), how='left')
timeseries.p = timeseries.p - timeseries.curtailment.fillna(0)
if self.timeseries_reactive is not None:
timeseries['q'] = self.timeseries_reactive
else:
timeseries['q'] = timeseries['p'] * self.q_sign * tan(acos(
self.power_factor))
return timeseries
else:
return self._timeseries.loc[
self.grid.network.timeseries.timeindex, :]
@property
def timeseries_reactive(self):
"""
Reactive power time series in kvar.
Parameters
-------
:pandas:`pandas.Series<series>`
Series containing reactive power time series in kvar.
Returns
----------
:pandas:`pandas.DataFrame<dataframe>` or None
Series containing reactive power time series in kvar. If it is not
set it is tried to be retrieved from `generation_reactive_power`
attribute of global TimeSeries object. If that is not possible
None is returned.
"""
if self._timeseries_reactive is None:
# try to get time series for reactive power depending on if they
# are differentiated by weather cell ID or not
# raise warning if no time series for generator type (and weather
# cell ID) can be retrieved
if self.grid.network.timeseries.generation_reactive_power \
is not None:
if isinstance(
self.grid.network.timeseries.generation_reactive_power.
columns, pd.MultiIndex):
if self.weather_cell_id:
try:
timeseries = self.grid.network.timeseries. \
generation_reactive_power[
self.type, self.weather_cell_id].to_frame('q')
return timeseries * self.nominal_capacity
except (KeyError, TypeError):
logger.warning("No time series for type {} and "
"weather cell ID {} given. "
"Reactive power time series will "
"be calculated from assumptions "
"in config files and active power "
"timeseries.".format(
self.type, self.weather_cell_id))
return None
else:
raise ValueError(
"No weather cell ID provided for fluctuating "
"generator {}, but reactive power is given as a "
"MultiIndex suggesting that it is differentiated "
"by weather cell ID.".format(repr(self)))
else:
try:
timeseries = self.grid.network.timeseries. \
generation_reactive_power[self.type].to_frame('q')
return timeseries * self.nominal_capacity
except (KeyError, TypeError):
logger.warning("No reactive power time series for "
"type {} given. Reactive power time "
"series will be calculated from "
"assumptions in config files and "
"active power timeseries.".format(
self.type))
return None
else:
return None
else:
return self._timeseries_reactive.loc[
self.grid.network.timeseries.timeindex, :]
@timeseries_reactive.setter
def timeseries_reactive(self, timeseries_reactive):
if isinstance(timeseries_reactive, pd.Series):
if timeseries_reactive.max() <= self._nominal_capacity:
self._timeseries_reactive = timeseries_reactive
self._power_factor = 'not_applicable'
self._reactive_power_mode = 'not_applicable'
else:
message = "Maximum reactive power in time series at " + \
"index {} ".format(timeseries_reactive.idxmax()) + \
"is higher than nominal capacity."
logger.error(message)
raise ValueError(message)
@property
def curtailment(self):
"""
Parameters
----------
curtailment_ts : :pandas:`pandas.Series<series>`
See class definition for details.
Returns
-------
:pandas:`pandas.Series<series>`
If self._curtailment is set it returns that. Otherwise, if
curtailment in :class:`~.grid.network.TimeSeries` for the
corresponding technology type (and if given, weather cell ID)
is set this is returned.
"""
if self._curtailment is not None:
return self._curtailment
elif isinstance(self.grid.network.timeseries._curtailment,
pd.DataFrame):
if isinstance(self.grid.network.timeseries.curtailment.
columns, pd.MultiIndex):
if self.weather_cell_id:
try:
return self.grid.network.timeseries.curtailment[
self.type, self.weather_cell_id]
except KeyError:
logger.exception("No curtailment time series for type "
"{} and weather cell ID {} "
"given.".format(self.type,
self.weather_cell_id))
raise
else:
logger.exception("No weather cell ID provided for "
"fluctuating generator {}.".format(
repr(self)))
raise KeyError
else:
try:
return self.grid.network.timeseries.curtailment[self.type]
except KeyError:
logger.exception("No curtailment time series for type "
"{} given.".format(self.type))
raise
else:
return None
@curtailment.setter
def curtailment(self, curtailment_ts):
self._curtailment = curtailment_ts
@property
def weather_cell_id(self):
"""
Get weather cell ID
Returns
-------
:obj:`str`
See class definition for details.
"""
return self._weather_cell_id
@weather_cell_id.setter
def weather_cell_id(self, weather_cell):
self._weather_cell_id = weather_cell
[docs]class Storage(Component):
"""Storage object
Describes a single storage instance in the eDisGo grid. Includes technical
parameters such as :attr:`Storage.efficiency_in` or
:attr:`Storage.standing_loss` as well as its time series of operation
:meth:`Storage.timeseries`.
"""
def __init__(self, **kwargs):
super().__init__(**kwargs)
self._timeseries = kwargs.get('timeseries', None)
self._nominal_power = kwargs.get('nominal_power', None)
self._power_factor = kwargs.get('power_factor', None)
self._reactive_power_mode = kwargs.get('reactive_power_mode', None)
self._max_hours = kwargs.get('max_hours', None)
self._soc_initial = kwargs.get('soc_initial', None)
self._efficiency_in = kwargs.get('efficiency_in', None)
self._efficiency_out = kwargs.get('efficiency_out', None)
self._standing_loss = kwargs.get('standing_loss', None)
self._operation = kwargs.get('operation', None)
self._reactive_power_mode = kwargs.get('reactive_power_mode', None)
self._q_sign = None
@property
def timeseries(self):
"""
Time series of storage operation
Parameters
----------
ts : :pandas:`pandas.DataFrame<dataframe>`
DataFrame containing active power the storage is charged (negative)
and discharged (positive) with (on the grid side) in kW in column
'p' and reactive power in kvar in column 'q'. When 'q' is positive,
reactive power is supplied (behaving as a capacitor) and when 'q'
is negative reactive power is consumed (behaving as an inductor).
Returns
-------
:pandas:`pandas.DataFrame<dataframe>`
See parameter `timeseries`.
"""
# check if time series for reactive power is given, otherwise
# calculate it
if 'q' in self._timeseries.columns:
return self._timeseries
else:
self._timeseries['q'] = abs(self._timeseries.p) * self.q_sign * \
tan(acos(self.power_factor))
return self._timeseries.loc[
self.grid.network.timeseries.timeindex, :]
@timeseries.setter
def timeseries(self, ts):
self._timeseries = ts
[docs] def pypsa_timeseries(self, attr):
"""Return time series in PyPSA format
Convert from kW, kVA to MW, MVA
Parameters
----------
attr : str
Attribute name (PyPSA conventions). Choose from {p_set, q_set}
"""
return self.timeseries[attr] / 1e3
@property
def nominal_power(self):
"""
Nominal charging and discharging power of storage instance in kW.
Returns
-------
float
Storage nominal power
"""
return self._nominal_power
@property
def max_hours(self):
"""
Maximum state of charge capacity in terms of hours at full discharging
power `nominal_power`.
Returns
-------
float
Hours storage can be discharged for at nominal power
"""
return self._max_hours
@property
def nominal_capacity(self):
"""
Nominal storage capacity in kWh.
Returns
-------
float
Storage nominal capacity
"""
return self._max_hours * self._nominal_power
@property
def soc_initial(self):
"""Initial state of charge in kWh.
Returns
-------
float
Initial state of charge
"""
return self._soc_initial
@property
def efficiency_in(self):
"""Storage charging efficiency in per unit.
Returns
-------
float
Charging efficiency in range of 0..1
"""
return self._efficiency_in
@property
def efficiency_out(self):
"""Storage discharging efficiency in per unit.
Returns
-------
float
Discharging efficiency in range of 0..1
"""
return self._efficiency_out
@property
def standing_loss(self):
"""Standing losses of storage in %/100 / h
Losses relative to SoC per hour. The unit is pu (%/100%). Hence, it
ranges from 0..1.
Returns
-------
float
Standing losses in pu.
"""
return self._standing_loss
@property
def operation(self):
"""
Storage operation definition
Returns
-------
:obj:`str`
"""
self._operation
@property
def power_factor(self):
"""
Power factor of storage
If power factor is not set it is retrieved from the network config
object depending on the grid level the storage is in.
Returns
--------
:obj:`float` : Power factor
Ratio of real power to apparent power.
"""
if self._power_factor is None:
if isinstance(self.grid, MVGrid):
self._power_factor = self.grid.network.config[
'reactive_power_factor']['mv_storage']
elif isinstance(self.grid, LVGrid):
self._power_factor = self.grid.network.config[
'reactive_power_factor']['lv_storage']
return self._power_factor
@power_factor.setter
def power_factor(self, power_factor):
self._power_factor = power_factor
@property
def reactive_power_mode(self):
"""
Power factor mode of storage.
If the power factor is set, then it is necessary to know whether
it is leading or lagging. In other words this information is necessary
to make the storage behave in an inductive or capacitive manner.
Essentially this changes the sign of the reactive power Q.
The convention used here in a storage is that:
- when `reactive_power_mode` is 'capacitive' then Q is positive
- when `reactive_power_mode` is 'inductive' then Q is negative
In the case that this attribute is not set, it is retrieved from the
network config object depending on the voltage level the storage
is in.
Returns
-------
:obj: `str` : Power factor mode
Either 'inductive' or 'capacitive'
"""
if self._reactive_power_mode is None:
if isinstance(self.grid, MVGrid):
self._reactive_power_mode = self.grid.network.config[
'reactive_power_mode']['mv_storage']
elif isinstance(self.grid, LVGrid):
self._reactive_power_mode = self.grid.network.config[
'reactive_power_mode']['lv_storage']
return self._reactive_power_mode
@reactive_power_mode.setter
def reactive_power_mode(self, reactive_power_mode):
"""
Set the power factor mode of the generator.
Should be either 'inductive' or 'capacitive'
"""
self._reactive_power_mode = reactive_power_mode
@property
def q_sign(self):
"""
Get the sign reactive power based on the
:attr: `_reactive_power_mode`
Returns
-------
:obj: `int` : +1 or -1
"""
if self.reactive_power_mode.lower() == 'inductive':
return -1
elif self.reactive_power_mode.lower() == 'capacitive':
return 1
else:
raise ValueError("Unknown value {} in reactive_power_mode".format(
self.reactive_power_mode))
def __repr__(self):
return str(self._id)
[docs]class MVDisconnectingPoint(Component):
"""Disconnecting point object
Medium voltage disconnecting points = points where MV rings are split under
normal operation conditions (= switch disconnectors in DINGO).
Attributes
----------
_nodes : tuple
Nodes of switch disconnector line segment
"""
def __init__(self, **kwargs):
super().__init__(**kwargs)
self._state = kwargs.get('state', None)
self._line = kwargs.get('line', None)
self._nodes = kwargs.get('nodes', None)
[docs] def open(self):
"""Toggle state to open switch disconnector"""
if self._state != 'open':
if self._line is not None:
self._state = 'open'
self._nodes = self.grid.graph.nodes_from_line(self._line)
self.grid.graph.remove_edge(
self._nodes[0], self._nodes[1])
else:
raise ValueError('``line`` is not set')
[docs] def close(self):
"""Toggle state to closed switch disconnector"""
self._state = 'closed'
self.grid.graph.add_edge(
self._nodes[0], self._nodes[1], line=self._line)
@property
def state(self):
"""
Get state of switch disconnector
Returns
-------
str or None
State of MV ring disconnector: 'open' or 'closed'.
Returns `None` if switch disconnector line segment is not set. This
refers to an open ring, but it's unknown if the grid topology was
built correctly.
"""
return self._state
@property
def line(self):
"""
Get or set line segment that belongs to the switch disconnector
The setter allows only to set the respective line initially. Once the
line segment representing the switch disconnector is set, it cannot be
changed.
Returns
-------
Line
Line segment that is part of the switch disconnector model
"""
return self._line
@line.setter
def line(self, line):
if self._line is None:
if isinstance(line, Line):
self._line = line
else:
raise TypeError('``line`` must be of type {}'.format(Line))
else:
raise ValueError('``line`` can only be set initially. Too late '
'dude!')
[docs]class BranchTee(Component):
"""Branch tee object
A branch tee is used to branch off a line to connect another node
(german: Abzweigmuffe)
"""
def __init__(self, **kwargs):
super().__init__(**kwargs)
self.in_building = kwargs.get('in_building', None)
# set id of BranchTee automatically if not provided
if not self._id:
ids = [_.id for _ in
self.grid.graph.nodes_by_attribute('branch_tee')]
if ids:
self._id = max(ids) + 1
else:
self._id = 1
def __repr__(self):
return '_'.join(
[self.__class__.__name__, repr(self.grid), str(self._id)])
[docs]class MVStation(Station):
"""MV Station object"""
def __init__(self, **kwargs):
super().__init__(**kwargs)
def __repr__(self, side=None):
repr_base = super().__repr__()
# As we don't consider HV-MV transformers in PFA, we don't have to care
# about primary side bus of MV station. Hence, the general repr()
# currently returned, implicitely refers to the secondary side (MV level)
# if side == 'hv':
# return '_'.join(['primary', repr_base])
# elif side == 'mv':
# return '_'.join(['secondary', repr_base])
# else:
# return repr_base
return repr_base
[docs]class LVStation(Station):
"""LV Station object"""
def __init__(self, **kwargs):
super().__init__(**kwargs)
self._mv_grid = kwargs.get('mv_grid', None)
@property
def mv_grid(self):
return self._mv_grid
def __repr__(self, side=None):
repr_base = super().__repr__()
if side == 'mv':
return '_'.join(['primary', repr_base])
elif side == 'lv':
return '_'.join(['secondary', repr_base])
else:
return repr_base
[docs]class Line(Component):
"""
Line object
Parameters
----------
_type: :pandas:`pandas.Series<series>`
Equipment specification including R and X for power flow analysis
Columns:
======== ================== ====== =========
Column Description Unit Data type
======== ================== ====== =========
name Name (e.g. NAYY..) - str
U_n Nominal voltage kV int
I_max_th Max. th. current A float
R Resistance Ohm/km float
L Inductance mH/km float
C Capacitance uF/km float
Source Data source - str
============================================
_length: float
Length of the line calculated in linear distance. Unit: m
_quantity: float
Quantity of parallel installed lines.
_kind: String
Specifies whether the line is an underground cable ('cable') or an
overhead line ('line').
"""
def __init__(self, **kwargs):
super().__init__(**kwargs)
self._type = kwargs.get('type', None)
self._length = kwargs.get('length', None)
self._quantity = kwargs.get('quantity', 1)
self._kind = kwargs.get('kind', None)
@property
def geom(self):
"""Provide :shapely:`Shapely LineString object<linestrings>` geometry of
:class:`Line`"""
adj_nodes = self._grid._graph.nodes_from_line(self)
return LineString([adj_nodes[0].geom, adj_nodes[1].geom])
@property
def type(self):
return self._type
@type.setter
def type(self, new_type):
self._type = new_type
@property
def length(self):
return self._length
@length.setter
def length(self, new_length):
self._length = new_length
@property
def quantity(self):
return self._quantity
@quantity.setter
def quantity(self, new_quantity):
self._quantity = new_quantity
@property
def kind(self):
return self._kind
@kind.setter
def kind(self, new_kind):
self._kind = new_kind