import networkx as nx
import os
import numpy as np
import pandas as pd
if not 'READTHEDOCS' in os.environ:
from shapely.geometry import Point
from edisgo.grid.components import LVStation, BranchTee, Generator, Load, \
MVDisconnectingPoint, Line, MVStation
from edisgo.grid.grids import LVGrid
from edisgo.flex_opt import exceptions
import logging
logger = logging.getLogger('edisgo')
[docs]def position_switch_disconnectors(mv_grid, mode='load', status='open'):
"""
Determine position of switch disconnector in MV grid rings
Determination of the switch disconnector location is motivated by placing
it to minimized load flows in both parts of the ring (half-rings).
The switch disconnecter will be installed to a LV station, unless none
exists in a ring. In this case, a node of arbitrary type is chosen for the
location of the switch disconnecter.
Parameters
----------
mv_grid : :class:`~.grid.grids.MVGrid`
MV grid instance
mode : str
Define modus switch disconnector positioning: can be performed based of
'load', 'generation' or both 'loadgen'. Defaults to 'load'
status : str
Either 'open' or 'closed'. Define which status is should be set
initially. Defaults to 'open' (which refers to conditions of normal
grid operation).
Returns
-------
tuple
A tuple of size 2 specifying their pair of nodes between which the
switch disconnector is located. The first node specifies the node that
actually includes the switch disconnector.
Notes
-----
This function uses `nx.algorithms.find_cycle()` to identify nodes that are
part of the MV grid ring(s). Make sure grid topology data that is provided
has closed rings. Otherwise, no location for a switch disconnector can be
identified.
"""
def peak_load_gen_at_node(node):
"""Return peak load and peak generation capacity for ``node``
Parameters
----------
node : object
Node instance in the grid topology graph
Returns
-------
tuple
Tuple of size two. First item is the peak load at ``node``; second
parameters reflects peak generation capacity at ``node``.
Returned peak_load and generation capacity is given as apparent
power in kVA.
"""
if isinstance(node, LVStation):
node_peak_load = node.grid.peak_load
node_peak_gen = node.grid.peak_generation
elif isinstance(node, Generator):
node_peak_load = 0
node_peak_gen = node.nominal_capacity
elif isinstance(node, Load):
node_peak_load = node.peak_load.sum()
node_peak_gen = 0
elif isinstance(node, BranchTee):
node_peak_load = 0
node_peak_gen = 0
return (node_peak_load / cos_phi_load, node_peak_gen / cos_phi_gen)
def load_gen_from_subtree(graph, ring, node):
"""
Accumulate load and generation capacity in branches to connecting node
on the ring.
Includes peak_load and generation capacity at ``node```itself.
Parameters
----------
graph : networkx.Graph
The graph representing the MV grid topology
ring : list
A list of ring nodes
node : networkx.Node
A member of the ring
Returns
-------
tuple
Tuple of size two. First item is the peak load of subtree at
``node``; second parameter reflects peak generation capacity from
subtree at ``node``.
"""
ring_nodes_except_node = [_ for _ in ring if _ is not node]
non_ring_nodes = [n for n in
[_ for _ in graph.nodes()
if _ is not mv_grid.station]
if n not in ring_nodes_except_node]
subgraph = graph.subgraph(non_ring_nodes)
nodes_subtree = nx.dfs_tree(subgraph, source=node)
if len(nodes_subtree) > 1:
peak_load_subtree = 0
peak_gen_subtree = 0
for n in nodes_subtree.nodes():
peak_load_subtree_tmp, peak_gen_subtree_tmp = \
peak_load_gen_at_node(n)
peak_load_subtree += peak_load_subtree_tmp
peak_gen_subtree += peak_gen_subtree_tmp
return (peak_load_subtree, peak_gen_subtree)
else:
return (0, 0)
cos_phi_load = mv_grid.network.config['reactive_power_factor']['mv_load']
cos_phi_gen = mv_grid.network.config['reactive_power_factor']['mv_gen']
# Identify position of switch disconnector (SD)
rings = nx.algorithms.cycle_basis(mv_grid.graph, root=mv_grid.station)
for ring in rings:
ring = [_ for _ in ring if _ is not mv_grid.station]
node_peak_load = []
node_peak_gen = []
# Collect peak load and generation along the ring
for node in ring:
if len(mv_grid.graph.edges(nbunch=node)) > 2:
peak_load, peak_gen = load_gen_from_subtree(
mv_grid.graph, ring, node)
else:
peak_load, peak_gen = peak_load_gen_at_node(node)
node_peak_load.append(peak_load)
node_peak_gen.append(peak_gen)
# Choose if SD is placed 'load' or 'generation' oriented
if mode == 'load':
node_peak_data = node_peak_load
elif mode == 'generation':
node_peak_data = node_peak_gen
elif mode == 'loadgen':
node_peak_data = node_peak_load if sum(node_peak_load) > sum(
node_peak_gen) else node_peak_gen
else:
raise ValueError("Mode {mode} is not known!".format(mode=mode))
# Set start value for difference in ring halfs
diff_min = 10e9
# if none of the nodes is of the type LVStation, a switch
# disconnecter will be installed anyways.
if any([isinstance(n, LVStation) for n in ring]):
has_lv_station = True
else:
has_lv_station = False
logging.debug("Ring {} does not have a LV station. "
"Switch disconnecter is installed at arbitrary "
"node.".format(ring))
# Identify nodes where switch disconnector is located in between
for ctr in range(len(node_peak_data)):
# check if node that owns the switch disconnector is of type
# LVStation
if isinstance(ring[ctr - 2], LVStation) or not has_lv_station:
# Iteratively split route and calc peak load difference
route_data_part1 = sum(node_peak_data[0:ctr])
route_data_part2 = sum(node_peak_data[ctr:len(node_peak_data)])
diff = abs(route_data_part1 - route_data_part2)
# stop walking through the ring when load/generation is almost
# equal
if diff <= diff_min:
diff_min = diff
position = ctr
else:
break
# find position of switch disconnector
node1 = ring[position - 1]
node2 = ring[position]
implement_switch_disconnector(mv_grid, node1, node2)
# open all switch disconnectors
if status == 'open':
for sd in mv_grid.graph.nodes_by_attribute('mv_disconnecting_point'):
sd.open()
elif status == 'close':
for sd in mv_grid.graph.nodes_by_attribute('mv_disconnecting_point'):
sd.close()
[docs]def implement_switch_disconnector(mv_grid, node1, node2):
"""
Install switch disconnector in grid topology
The graph that represents the grid's topology is altered in such way that
it explicitly includes a switch disconnector.
The switch disconnector is always located at ``node1``. Technically, it
does not make any difference. This is just an convention ensuring
consistency of multiple runs.
The ring is still closed after manipulations of this function.
Parameters
----------
mv_grid : :class:`~.grid.grids.MVGrid`
MV grid instance
node1
A rings node
node2
Another rings node
"""
# Get disconnecting point's location
line = mv_grid.graph.edges[node1, node2]['line']
length_sd_line = .75e-3 # in km
x_sd = node1.geom.x + (length_sd_line / line.length) * (
node1.geom.x - node2.geom.x)
y_sd = node1.geom.y + (length_sd_line / line.length) * (
node1.geom.y - node2.geom.y)
# Instantiate disconnecting point
mv_dp_number = len(mv_grid.graph.nodes_by_attribute(
'mv_disconnecting_point'))
disconnecting_point = MVDisconnectingPoint(
id=mv_dp_number + 1,
geom=Point(x_sd, y_sd),
grid=mv_grid)
mv_grid.graph.add_node(disconnecting_point, type='mv_disconnecting_point')
# Replace original line by a new line
new_line_attr = {
'line': Line(
id=line.id,
type=line.type,
length=line.length - length_sd_line,
grid=mv_grid),
'type': 'line'}
mv_grid.graph.remove_edge(node1, node2)
mv_grid.graph.add_edge(disconnecting_point, node2, **new_line_attr)
# Add disconnecting line segment
switch_disconnector_line_attr = {
'line': Line(
id="switch_disconnector_line_{}".format(
str(mv_dp_number + 1)),
type=line.type,
length=length_sd_line,
grid=mv_grid),
'type': 'line'}
mv_grid.graph.add_edge(node1, disconnecting_point,
**switch_disconnector_line_attr)
# Set line to switch disconnector
disconnecting_point.line = mv_grid.graph.line_from_nodes(
disconnecting_point, node1)
[docs]def select_cable(network, level, apparent_power):
"""Selects an appropriate cable type and quantity using given apparent
power.
Considers load factor.
Parameters
----------
network : :class:`~.grid.network.Network`
The eDisGo container object
level : :obj:`str`
Grid level ('mv' or 'lv')
apparent_power : :obj:`float`
Apparent power the cable must carry in kVA
Returns
-------
:pandas:`pandas.Series<series>`
Cable type
:obj:`ìnt`
Cable count
Notes
------
Cable is selected to be able to carry the given `apparent_power`, no load
factor is considered.
"""
cable_count = 1
if level == 'mv':
available_cables = network.equipment_data['mv_cables'][
network.equipment_data['mv_cables']['U_n'] ==
network.mv_grid.voltage_nom]
suitable_cables = available_cables[
available_cables['I_max_th'] *
network.mv_grid.voltage_nom > apparent_power]
# increase cable count until appropriate cable type is found
while suitable_cables.empty and cable_count < 20:
cable_count += 1
suitable_cables = available_cables[
available_cables['I_max_th'] *
network.mv_grid.voltage_nom *
cable_count > apparent_power]
if suitable_cables.empty and cable_count == 20:
raise exceptions.MaximumIterationError(
"Could not find a suitable cable for apparent power of "
"{} kVA.".format(apparent_power))
cable_type = suitable_cables.ix[suitable_cables['I_max_th'].idxmin()]
elif level == 'lv':
suitable_cables = network.equipment_data['lv_cables'][
network.equipment_data['lv_cables']['I_max_th'] *
network.equipment_data['lv_cables']['U_n'] > apparent_power]
# increase cable count until appropriate cable type is found
while suitable_cables.empty and cable_count < 20:
cable_count += 1
suitable_cables = network.equipment_data['lv_cables'][
network.equipment_data['lv_cables']['I_max_th'] *
network.equipment_data['lv_cables']['U_n'] *
cable_count > apparent_power]
if suitable_cables.empty and cable_count == 20:
raise exceptions.MaximumIterationError(
"Could not find a suitable cable for apparent power of "
"{} kVA.".format(apparent_power))
cable_type = suitable_cables.ix[suitable_cables['I_max_th'].idxmin()]
else:
raise ValueError('Please supply a level (either \'mv\' or \'lv\').')
return cable_type, cable_count
[docs]def get_gen_info(network, level='mvlv', fluctuating=False):
"""
Gets all the installed generators with some additional information.
Parameters
----------
network : :class:`~.grid.network.Network`
Network object holding the grid data.
level : :obj:`str`
Defines which generators are returned. Possible options are:
* 'mv'
Only generators connected to the MV grid are returned.
* 'lv'
Only generators connected to the LV grids are returned.
* 'mvlv'
All generators connected to the MV grid and LV grids are returned.
Default: 'mvlv'.
fluctuating : :obj:`bool`
If True only returns fluctuating generators. Default: False.
Returns
--------
:pandas:`pandas.DataFrame<dataframe>`
Dataframe with all generators connected to the specified voltage
level. Index of the dataframe are the generator objects of type
:class:`~.grid.components.Generator`. Columns of the dataframe are:
* 'gen_repr'
The representative of the generator as :obj:`str`.
* 'type'
The generator type, e.g. 'solar' or 'wind' as :obj:`str`.
* 'voltage_level'
The voltage level the generator is connected to as :obj:`str`. Can
either be 'mv' or 'lv'.
* 'nominal_capacity'
The nominal capacity of the generator as as :obj:`float`.
* 'weather_cell_id'
The id of the weather cell the generator is located in as :obj:`int`
(only applies to fluctuating generators).
"""
gens_w_id = []
if 'mv' in level:
gens = network.mv_grid.generators
gens_voltage_level = ['mv']*len(gens)
gens_type = [gen.type for gen in gens]
gens_rating = [gen.nominal_capacity for gen in gens]
for gen in gens:
try:
gens_w_id.append(gen.weather_cell_id)
except AttributeError:
gens_w_id.append(np.nan)
gens_grid = [network.mv_grid]*len(gens)
else:
gens = []
gens_voltage_level = []
gens_type = []
gens_rating = []
gens_grid = []
if 'lv' in level:
for lv_grid in network.mv_grid.lv_grids:
gens_lv = lv_grid.generators
gens.extend(gens_lv)
gens_voltage_level.extend(['lv']*len(gens_lv))
gens_type.extend([gen.type for gen in gens_lv])
gens_rating.extend([gen.nominal_capacity for gen in gens_lv])
for gen in gens_lv:
try:
gens_w_id.append(gen.weather_cell_id)
except AttributeError:
gens_w_id.append(np.nan)
gens_grid.extend([lv_grid] * len(gens_lv))
gen_df = pd.DataFrame({'gen_repr': list(map(lambda x: repr(x), gens)),
'generator': gens,
'type': gens_type,
'voltage_level': gens_voltage_level,
'nominal_capacity': gens_rating,
'weather_cell_id': gens_w_id,
'grid': gens_grid})
gen_df.set_index('generator', inplace=True, drop=True)
# filter fluctuating generators
if fluctuating:
gen_df = gen_df.loc[(gen_df.type == 'solar') | (gen_df.type == 'wind')]
return gen_df
[docs]def assign_mv_feeder_to_nodes(mv_grid):
"""
Assigns an MV feeder to every generator, LV station, load, and branch tee
Parameters
-----------
mv_grid : :class:`~.grid.grids.MVGrid`
"""
mv_station_neighbors = list(mv_grid.graph.neighbors(mv_grid.station))
# get all nodes in MV grid and remove MV station to get separate subgraphs
mv_graph_nodes = list(mv_grid.graph.nodes())
mv_graph_nodes.remove(mv_grid.station)
subgraph = mv_grid.graph.subgraph(mv_graph_nodes)
for neighbor in mv_station_neighbors:
# determine feeder
mv_feeder = mv_grid.graph.line_from_nodes(mv_grid.station, neighbor)
# get all nodes in that feeder by doing a DFS in the disconnected
# subgraph starting from the node adjacent to the MVStation `neighbor`
subgraph_neighbor = nx.dfs_tree(subgraph, source=neighbor)
for node in subgraph_neighbor.nodes():
# in case of an LV station assign feeder to all nodes in that LV
# grid
if isinstance(node, LVStation):
for lv_node in node.grid.graph.nodes():
lv_node.mv_feeder = mv_feeder
else:
node.mv_feeder = mv_feeder
[docs]def get_mv_feeder_from_line(line):
"""
Determines MV feeder the given line is in.
MV feeders are identified by the first line segment of the half-ring.
Parameters
----------
line : :class:`~.grid.components.Line`
Line to find the MV feeder for.
Returns
-------
:class:`~.grid.components.Line`
MV feeder identifier (representative of the first line segment
of the half-ring)
"""
try:
# get nodes of line
nodes = line.grid.graph.nodes_from_line(line)
# get feeders
feeders = {}
for node in nodes:
# if one of the nodes is an MV station the line is an MV feeder
# itself
if isinstance(node, MVStation):
feeders[repr(node)] = None
else:
feeders[repr(node)] = node.mv_feeder
# return feeder that is not None
feeder_1 = feeders[repr(nodes[0])]
feeder_2 = feeders[repr(nodes[1])]
if not feeder_1 is None and not feeder_2 is None:
if feeder_1 == feeder_2:
return feeder_1
else:
logging.warning('Different feeders for line {}.'.format(line))
return None
else:
return feeder_1 if feeder_1 is not None else feeder_2
except Exception as e:
logging.warning('Failed to get MV feeder: {}.'.format(e))
return None
[docs]def disconnect_storage(network, storage):
"""
Removes storage from network graph and pypsa representation.
Parameters
-----------
network : :class:`~.grid.network.Network`
storage : :class:`~.grid.components.Storage`
Storage instance to be removed.
"""
# does only remove from network.pypsa, not from network.pypsa_lopf
# remove from pypsa (buses, storage_units, storage_units_t, lines)
neighbor = list(storage.grid.graph.neighbors(storage))[0]
if network.pypsa is not None:
line = storage.grid.graph.line_from_nodes(storage, neighbor)
network.pypsa.storage_units = network.pypsa.storage_units.loc[
network.pypsa.storage_units.index.drop(
repr(storage)), :]
network.pypsa.storage_units_t.p_set.drop([repr(storage)], axis=1,
inplace=True)
network.pypsa.storage_units_t.q_set.drop([repr(storage)], axis=1,
inplace=True)
network.pypsa.buses = network.pypsa.buses.loc[
network.pypsa.buses.index.drop(
'_'.join(['Bus', repr(storage)])), :]
network.pypsa.lines = network.pypsa.lines.loc[
network.pypsa.lines.index.drop(
repr(line)), :]
# delete line
neighbor = list(storage.grid.graph.neighbors(storage))[0]
storage.grid.graph.remove_edge(storage, neighbor)
# delete storage
storage.grid.graph.remove_node(storage)