import networkx as nx
from networkx.algorithms.shortest_paths.weighted import _dijkstra as \
dijkstra_shortest_path_length
import pandas as pd
import numpy as np
from math import sqrt, ceil
from edisgo.grid import tools
from edisgo.grid.components import LVStation
from edisgo.flex_opt import check_tech_constraints, costs
from edisgo.tools import plots
import logging
logger = logging.getLogger('edisgo')
[docs]def one_storage_per_feeder(edisgo, storage_timeseries,
storage_nominal_power=None, **kwargs):
"""
Allocates the given storage capacity to multiple smaller storages.
For each feeder with load or voltage issues it is checked if integrating a
storage will reduce peaks in the feeder, starting with the feeder with
the highest theoretical grid expansion costs. A heuristic approach is used
to estimate storage sizing and siting while storage operation is carried
over from the given storage operation.
Parameters
-----------
edisgo : :class:`~.grid.network.EDisGo`
storage_timeseries : :pandas:`pandas.DataFrame<dataframe>`
Total active and reactive power time series that will be allocated to
the smaller storages in feeders with load or voltage issues. Columns of
the dataframe are 'p' containing active power time series in kW and 'q'
containing the reactive power time series in kvar. Index is a
:pandas:`pandas.DatetimeIndex<datetimeindex>`.
storage_nominal_power : :obj:`float` or None
Nominal power in kW that will be allocated to the smaller storages in
feeders with load or voltage issues. If no nominal power is provided
the maximum active power given in `storage_timeseries` is used.
Default: None.
debug : :obj:`Boolean`, optional
If dedug is True a dataframe with storage size and path to storage of
all installed and possibly discarded storages is saved to a csv file
and a plot with all storage positions is created and saved, both to the
current working directory with filename `storage_results_{MVgrid_id}`.
Default: False.
check_costs_reduction : :obj:`Boolean` or :obj:`str`, optional
This parameter specifies when and whether it should be checked if a
storage reduced grid expansion costs or not. It can be used as a safety
check but can be quite time consuming. Possible options are:
* 'each_feeder'
Costs reduction is checked for each feeder. If the storage did not
reduce grid expansion costs it is discarded.
* 'once'
Costs reduction is checked after the total storage capacity is
allocated to the feeders. If the storages did not reduce grid
expansion costs they are all discarded.
* False
Costs reduction is never checked.
Default: False.
"""
def _feeder_ranking(grid_expansion_costs):
"""
Get feeder ranking from grid expansion costs DataFrame.
MV feeders are ranked descending by grid expansion costs that are
attributed to that feeder.
Parameters
----------
grid_expansion_costs : :pandas:`pandas.DataFrame<dataframe>`
grid_expansion_costs DataFrame from :class:`~.grid.network.Results`
of the copied edisgo object.
Returns
-------
:pandas:`pandas.Series<series>`
Series with ranked MV feeders (in the copied graph) of type
:class:`~.grid.components.Line`. Feeders are ranked by total grid
expansion costs of all measures conducted in the feeder. The
feeder with the highest costs is in the first row and the feeder
with the lowest costs in the last row.
"""
return grid_expansion_costs.groupby(
['mv_feeder'], sort=False).sum().reset_index().sort_values(
by=['total_costs'], ascending=False)['mv_feeder']
def _shortest_path(node):
if isinstance(node, LVStation):
return len(nx.shortest_path(
node.mv_grid.graph, node.mv_grid.station, node))
else:
return len(nx.shortest_path(
node.grid.graph, node.grid.station, node))
def _find_battery_node(edisgo, critical_lines_feeder,
critical_nodes_feeder):
"""
Evaluates where to install the storage.
Parameters
-----------
edisgo : :class:`~.grid.network.EDisGo`
The original edisgo object.
critical_lines_feeder : :pandas:`pandas.DataFrame<dataframe>`
Dataframe containing over-loaded lines in MV feeder, their maximum
relative over-loading and the corresponding time step. See
:func:`edisgo.flex_opt.check_tech_constraints.mv_line_load` for
more information.
critical_nodes_feeder : :obj:`list`
List with all nodes in MV feeder with voltage issues.
Returns
-------
:obj:`float`
Node where storage is installed.
"""
# if there are overloaded lines in the MV feeder the battery storage
# will be installed at the node farthest away from the MV station
if not critical_lines_feeder.empty:
logger.debug("Storage positioning due to overload.")
# dictionary with nodes and their corresponding path length to
# MV station
path_length_dict = {}
for l in critical_lines_feeder.index:
nodes = l.grid.graph.nodes_from_line(l)
for node in nodes:
path_length_dict[node] = _shortest_path(node)
# return node farthest away
return [_ for _ in path_length_dict.keys()
if path_length_dict[_] == max(
path_length_dict.values())][0]
# if there are voltage issues in the MV grid the battery storage will
# be installed at the first node in path that exceeds 2/3 of the line
# length from station to critical node with highest voltage deviation
if critical_nodes_feeder:
logger.debug("Storage positioning due to voltage issues.")
node = critical_nodes_feeder[0]
# get path length from station to critical node
get_weight = lambda u, v, data: data['line'].length
path_length = dijkstra_shortest_path_length(
edisgo.network.mv_grid.graph,
edisgo.network.mv_grid.station,
get_weight, target=node)
# find first node in path that exceeds 2/3 of the line length
# from station to critical node farthest away from the station
path = nx.shortest_path(edisgo.network.mv_grid.graph,
edisgo.network.mv_grid.station,
node)
return next(j for j in path
if path_length[j] >= path_length[node] * 2 / 3)
return None
def _calc_storage_size(edisgo, feeder, max_storage_size):
"""
Calculates storage size that reduces residual load.
Parameters
-----------
edisgo : :class:`~.grid.network.EDisGo`
The original edisgo object.
feeder : :class:`~.grid.components.Line`
MV feeder the storage will be connected to. The line object is an
object from the copied graph.
Returns
-------
:obj:`float`
Storage size that reduced the residual load in the feeder.
"""
step_size = 200
sizes = [0] + list(np.arange(
p_storage_min, max_storage_size + 0.5 * step_size, step_size))
p_feeder = edisgo.network.results.pfa_p.loc[:, repr(feeder)]
q_feeder = edisgo.network.results.pfa_q.loc[:, repr(feeder)]
p_slack = edisgo.network.pypsa.generators_t.p.loc[
:, 'Generator_slack'] * 1e3
# get sign of p and q
l = edisgo.network.pypsa.lines.loc[repr(feeder), :]
mv_station_bus = 'bus0' if l.loc['bus0'] == 'Bus_'.format(
repr(edisgo.network.mv_grid.station)) else 'bus1'
if mv_station_bus == 'bus0':
diff = edisgo.network.pypsa.lines_t.p1.loc[:, repr(feeder)] - \
edisgo.network.pypsa.lines_t.p0.loc[:, repr(feeder)]
diff_q = edisgo.network.pypsa.lines_t.q1.loc[:, repr(feeder)] - \
edisgo.network.pypsa.lines_t.q0.loc[:, repr(feeder)]
else:
diff = edisgo.network.pypsa.lines_t.p0.loc[:, repr(feeder)] - \
edisgo.network.pypsa.lines_t.p1.loc[:, repr(feeder)]
diff_q = edisgo.network.pypsa.lines_t.q0.loc[:, repr(feeder)] - \
edisgo.network.pypsa.lines_t.q1.loc[:, repr(feeder)]
p_sign = pd.Series([-1 if _ < 0 else 1 for _ in diff],
index=p_feeder.index)
q_sign = pd.Series([-1 if _ < 0 else 1 for _ in diff_q],
index=p_feeder.index)
# get allowed load factors per case
lf = {'feedin_case': edisgo.network.config[
'grid_expansion_load_factors']['mv_feedin_case_line'],
'load_case': network.config[
'grid_expansion_load_factors']['mv_load_case_line']}
# calculate maximum apparent power for each storage size to find
# storage size that minimizes apparent power in the feeder
p_feeder = p_feeder.multiply(p_sign)
q_feeder = q_feeder.multiply(q_sign)
s_max = []
for size in sizes:
share = size / storage_nominal_power
p_storage = storage_timeseries.p * share
q_storage = storage_timeseries.q * share
p_total = p_feeder + p_storage
q_total = q_feeder + q_storage
p_hv_mv_station = p_slack - p_storage
lf_ts = p_hv_mv_station.apply(
lambda _: lf['feedin_case'] if _ < 0 else lf['load_case'])
s_max_ts = (p_total ** 2 + q_total ** 2).apply(
sqrt).divide(lf_ts)
s_max.append(max(s_max_ts))
return sizes[pd.Series(s_max).idxmin()]
def _critical_nodes_feeder(edisgo, feeder):
"""
Returns all nodes in MV feeder with voltage issues.
Parameters
-----------
edisgo : :class:`~.grid.network.EDisGo`
The original edisgo object.
feeder : :class:`~.grid.components.Line`
MV feeder the storage will be connected to. The line object is an
object from the copied graph.
Returns
-------
:obj:`list`
List with all nodes in MV feeder with voltage issues.
"""
# get all nodes with voltage issues in MV grid
critical_nodes = check_tech_constraints.mv_voltage_deviation(
edisgo.network, voltage_levels='mv')
if critical_nodes:
critical_nodes = critical_nodes[edisgo.network.mv_grid]
else:
return []
# filter nodes with voltage issues in feeder
critical_nodes_feeder = []
for n in critical_nodes.index:
if repr(n.mv_feeder) == repr(feeder):
critical_nodes_feeder.append(n)
return critical_nodes_feeder
def _critical_lines_feeder(edisgo, feeder):
"""
Returns all lines in MV feeder with overload issues.
Parameters
-----------
edisgo : :class:`~.grid.network.EDisGo`
The original edisgo object.
feeder : :class:`~.grid.components.Line`
MV feeder the storage will be connected to. The line object is an
object from the copied graph.
Returns
-------
:pandas:`pandas.DataFrame<dataframe>`
Dataframe containing over-loaded lines in MV feeder, their maximum
relative over-loading and the corresponding time step. See
:func:`edisgo.flex_opt.check_tech_constraints.mv_line_load` for
more information.
"""
# get all overloaded MV lines
critical_lines = check_tech_constraints.mv_line_load(edisgo.network)
# filter overloaded lines in feeder
critical_lines_feeder = []
for l in critical_lines.index:
if repr(tools.get_mv_feeder_from_line(l)) == repr(feeder):
critical_lines_feeder.append(l)
return critical_lines.loc[critical_lines_feeder, :]
def _estimate_new_number_of_lines(critical_lines_feeder):
number_parallel_lines = 0
for crit_line in critical_lines_feeder.index:
number_parallel_lines += ceil(critical_lines_feeder.loc[
crit_line, 'max_rel_overload'] * crit_line.quantity) - \
crit_line.quantity
return number_parallel_lines
debug = kwargs.get('debug', False)
check_costs_reduction = kwargs.get('check_costs_reduction', False)
# global variables
# minimum and maximum storage power to be connected to the MV grid
p_storage_min = 300
p_storage_max = 4500
# remaining storage nominal power
if storage_nominal_power is None:
storage_nominal_power = max(abs(storage_timeseries.p))
p_storage_remaining = storage_nominal_power
if debug:
feeder_repr = []
storage_path = []
storage_repr = []
storage_size = []
# rank MV feeders by grid expansion costs
# conduct grid reinforcement on copied edisgo object on worst-case time
# steps
grid_expansion_results_init = edisgo.reinforce(
copy_graph=True, timesteps_pfa='snapshot_analysis')
# only analyse storage integration if there were any grid expansion needs
if grid_expansion_results_init.equipment_changes.empty:
logger.debug('No storage integration necessary since there are no '
'grid expansion needs.')
return
else:
equipment_changes_reinforcement_init = \
grid_expansion_results_init.equipment_changes.loc[
grid_expansion_results_init.equipment_changes.iteration_step >
0]
total_grid_expansion_costs = \
grid_expansion_results_init.grid_expansion_costs.total_costs.sum()
if equipment_changes_reinforcement_init.empty:
logger.debug('No storage integration necessary since there are no '
'grid expansion needs.')
return
else:
network = equipment_changes_reinforcement_init.index[
0].grid.network
# calculate grid expansion costs without costs for new generators
# to be used in feeder ranking
grid_expansion_costs_feeder_ranking = costs.grid_expansion_costs(
network, without_generator_import=True)
ranked_feeders = _feeder_ranking(grid_expansion_costs_feeder_ranking)
count = 1
storage_obj_list = []
total_grid_expansion_costs_new = 'not calculated'
for feeder in ranked_feeders.values:
logger.debug('Feeder: {}'.format(count))
count += 1
# first step: find node where storage will be installed
critical_nodes_feeder = _critical_nodes_feeder(edisgo, feeder)
critical_lines_feeder = _critical_lines_feeder(edisgo, feeder)
# get node the storage will be connected to (in original graph)
battery_node = _find_battery_node(edisgo, critical_lines_feeder,
critical_nodes_feeder)
if battery_node:
# add to output lists
if debug:
feeder_repr.append(repr(feeder))
storage_path.append(nx.shortest_path(
edisgo.network.mv_grid.graph,
edisgo.network.mv_grid.station,
battery_node))
# second step: calculate storage size
max_storage_size = min(p_storage_remaining, p_storage_max)
p_storage = _calc_storage_size(edisgo, feeder, max_storage_size)
# if p_storage is greater than or equal to the minimum storage
# power required, do storage integration
if p_storage >= p_storage_min:
# third step: integrate storage
share = p_storage / storage_nominal_power
edisgo.integrate_storage(
timeseries=storage_timeseries.p * share,
position=battery_node,
voltage_level='mv',
timeseries_reactive_power=storage_timeseries.q * share)
tools.assign_mv_feeder_to_nodes(edisgo.network.mv_grid)
# get new storage object
storage_obj = [_
for _ in
edisgo.network.mv_grid.graph.nodes_by_attribute(
'storage') if _ in
list(edisgo.network.mv_grid.graph.neighbors(
battery_node))][0]
storage_obj_list.append(storage_obj)
logger.debug(
'Storage with nominal power of {} kW connected to '
'node {} (path to HV/MV station {}).'.format(
p_storage, battery_node, nx.shortest_path(
battery_node.grid.graph, battery_node.grid.station,
battery_node)))
# fourth step: check if storage integration reduced grid
# reinforcement costs or number of issues
if check_costs_reduction == 'each_feeder':
# calculate new grid expansion costs
grid_expansion_results_new = edisgo.reinforce(
copy_graph=True, timesteps_pfa='snapshot_analysis')
total_grid_expansion_costs_new = \
grid_expansion_results_new.grid_expansion_costs.\
total_costs.sum()
costs_diff = total_grid_expansion_costs - \
total_grid_expansion_costs_new
if costs_diff > 0:
logger.debug(
'Storage integration in feeder {} reduced grid '
'expansion costs by {} kEuro.'.format(
feeder, costs_diff))
if debug:
storage_repr.append(repr(storage_obj))
storage_size.append(storage_obj.nominal_power)
total_grid_expansion_costs = \
total_grid_expansion_costs_new
else:
logger.debug(
'Storage integration in feeder {} did not reduce '
'grid expansion costs (costs increased by {} '
'kEuro).'.format(feeder, -costs_diff))
tools.disconnect_storage(edisgo.network, storage_obj)
p_storage = 0
if debug:
storage_repr.append(None)
storage_size.append(0)
edisgo.integrate_storage(
timeseries=storage_timeseries.p * 0,
position=battery_node,
voltage_level='mv',
timeseries_reactive_power=
storage_timeseries.q * 0)
tools.assign_mv_feeder_to_nodes(
edisgo.network.mv_grid)
else:
number_parallel_lines_before = \
_estimate_new_number_of_lines(critical_lines_feeder)
edisgo.analyze()
critical_lines_feeder_new = _critical_lines_feeder(
edisgo, feeder)
critical_nodes_feeder_new = _critical_nodes_feeder(
edisgo, feeder)
number_parallel_lines = _estimate_new_number_of_lines(
critical_lines_feeder_new)
# if there are critical lines check if number of parallel
# lines was reduced
if not critical_lines_feeder.empty:
diff_lines = number_parallel_lines_before - \
number_parallel_lines
# if it was not reduced check if there are critical
# nodes and if the number was reduced
if diff_lines <= 0:
# if there are no critical nodes remove storage
if not critical_nodes_feeder:
logger.debug(
'Storage integration in feeder {} did not '
'reduce number of critical lines (number '
'increased by {}), storage '
'is therefore removed.'.format(
feeder, -diff_lines))
tools.disconnect_storage(edisgo.network,
storage_obj)
p_storage = 0
if debug:
storage_repr.append(None)
storage_size.append(0)
edisgo.integrate_storage(
timeseries=storage_timeseries.p * 0,
position=battery_node,
voltage_level='mv',
timeseries_reactive_power=
storage_timeseries.q * 0)
tools.assign_mv_feeder_to_nodes(
edisgo.network.mv_grid)
else:
logger.debug(
'Critical nodes in feeder {} '
'before and after storage integration: '
'{} vs. {}'.format(
feeder, critical_nodes_feeder,
critical_nodes_feeder_new))
if debug:
storage_repr.append(repr(storage_obj))
storage_size.append(
storage_obj.nominal_power)
else:
logger.debug(
'Storage integration in feeder {} reduced '
'number of critical lines.'.format(feeder))
if debug:
storage_repr.append(repr(storage_obj))
storage_size.append(storage_obj.nominal_power)
# if there are no critical lines
else:
logger.debug(
'Critical nodes in feeder {} '
'before and after storage integration: '
'{} vs. {}'.format(
feeder, critical_nodes_feeder,
critical_nodes_feeder_new))
if debug:
storage_repr.append(repr(storage_obj))
storage_size.append(storage_obj.nominal_power)
# fifth step: if there is storage capacity left, rerun
# the past steps for the next feeder in the ranking
# list
p_storage_remaining = p_storage_remaining - p_storage
if not p_storage_remaining > p_storage_min:
break
else:
logger.debug('No storage integration in feeder {}.'.format(
feeder))
if debug:
storage_repr.append(None)
storage_size.append(0)
edisgo.integrate_storage(
timeseries=storage_timeseries.p * 0,
position=battery_node,
voltage_level='mv',
timeseries_reactive_power=storage_timeseries.q * 0)
tools.assign_mv_feeder_to_nodes(edisgo.network.mv_grid)
else:
logger.debug('No storage integration in feeder {} because there '
'are neither overloading nor voltage issues.'.format(
feeder))
if debug:
storage_repr.append(None)
storage_size.append(0)
feeder_repr.append(repr(feeder))
storage_path.append([])
if check_costs_reduction == 'once':
# check costs reduction and discard all storages if costs were not
# reduced
grid_expansion_results_new = edisgo.reinforce(
copy_graph=True, timesteps_pfa='snapshot_analysis')
total_grid_expansion_costs_new = \
grid_expansion_results_new.grid_expansion_costs. \
total_costs.sum()
costs_diff = total_grid_expansion_costs - \
total_grid_expansion_costs_new
if costs_diff > 0:
logger.info(
'Storage integration in grid {} reduced grid '
'expansion costs by {} kEuro.'.format(
edisgo.network.id, costs_diff))
else:
logger.info(
'Storage integration in grid {} did not reduce '
'grid expansion costs (costs increased by {} '
'kEuro).'.format(edisgo.network.id, -costs_diff))
for storage in storage_obj_list:
tools.disconnect_storage(edisgo.network, storage)
elif check_costs_reduction == 'each_feeder':
# if costs redcution was checked after each storage only give out
# total costs reduction
if total_grid_expansion_costs_new == 'not calculated':
costs_diff = 0
else:
total_grid_expansion_costs = grid_expansion_results_init.\
grid_expansion_costs.total_costs.sum()
costs_diff = total_grid_expansion_costs - \
total_grid_expansion_costs_new
logger.info(
'Storage integration in grid {} reduced grid '
'expansion costs by {} kEuro.'.format(
edisgo.network.id, costs_diff))
if debug:
plots.storage_size(edisgo.network.mv_grid, edisgo.network.pypsa,
filename='storage_results_{}.pdf'.format(
edisgo.network.id), lopf=False)
storages_df = pd.DataFrame({'path': storage_path,
'repr': storage_repr,
'p_nom': storage_size},
index=feeder_repr)
storages_df.to_csv('storage_results_{}.csv'.format(edisgo.network.id))
edisgo.network.results.storages_costs_reduction = pd.DataFrame(
{'grid_expansion_costs_initial': total_grid_expansion_costs,
'grid_expansion_costs_with_storages': total_grid_expansion_costs_new},
index=[edisgo.network.id])