def mv_grid_topology(pypsa_network, configs, timestep=None, line_color=None, node_color=None, line_load=None, grid_expansion_costs=None, filename=None, arrows=False, grid_district_geom=True, background_map=True, voltage=None, limits_cb_lines=None, limits_cb_nodes=None, xlim=None, ylim=None, lines_cmap='inferno_r', title='', scaling_factor_line_width=None): """ Plot line loading as color on lines. Displays line loading relative to nominal capacity. Parameters ---------- pypsa_network : :pypsa:`pypsa.Network<network>` configs : :obj:`dict` Dictionary with used configurations from config files. See :class:`~.grid.network.Config` for more information. timestep : :pandas:`pandas.Timestamp<timestamp>` Time step to plot analysis results for. If `timestep` is None maximum line load and if given, maximum voltage deviation, is used. In that case arrows cannot be drawn. Default: None. line_color : :obj:`str` or None Defines whereby to choose line colors (and implicitly size). Possible options are: * 'loading' Line color is set according to loading of the line. Loading of MV lines must be provided by parameter `line_load`. * 'expansion_costs' Line color is set according to investment costs of the line. This option also effects node colors and sizes by plotting investment in stations and setting `node_color` to 'storage_integration' in order to plot storage size of integrated storages. Grid expansion costs must be provided by parameter `grid_expansion_costs`. * None (default) Lines are plotted in black. Is also the fallback option in case of wrong input. node_color : :obj:`str` or None Defines whereby to choose node colors (and implicitly size). Possible options are: * 'technology' Node color as well as size is set according to type of node (generator, MV station, etc.). * 'voltage' Node color is set according to voltage deviation from 1 p.u.. Voltages of nodes in MV grid must be provided by parameter `voltage`. * 'storage_integration' Only storages are plotted. Size of node corresponds to size of storage. * None (default) Nodes are not plotted. Is also the fallback option in case of wrong input. line_load : :pandas:`pandas.DataFrame<dataframe>` or None Dataframe with current results from power flow analysis in A. Index of the dataframe is a :pandas:`pandas.DatetimeIndex<datetimeindex>`, columns are the line representatives. Only needs to be provided when parameter `line_color` is set to 'loading'. Default: None. grid_expansion_costs : :pandas:`pandas.DataFrame<dataframe>` or None Dataframe with grid expansion costs in kEUR. See `grid_expansion_costs` in :class:`~.grid.network.Results` for more information. Only needs to be provided when parameter `line_color` is set to 'expansion_costs'. Default: None. filename : :obj:`str` Filename to save plot under. If not provided, figure is shown directly. Default: None. arrows : :obj:`Boolean` If True draws arrows on lines in the direction of the power flow. Does only work when `line_color` option 'loading' is used and a time step is given. Default: False. grid_district_geom : :obj:`Boolean` If True grid district polygon is plotted in the background. This also requires the geopandas package to be installed. Default: True. background_map : :obj:`Boolean` If True map is drawn in the background. This also requires the contextily package to be installed. Default: True. voltage : :pandas:`pandas.DataFrame<dataframe>` Dataframe with voltage results from power flow analysis in p.u.. Index of the dataframe is a :pandas:`pandas.DatetimeIndex<datetimeindex>`, columns are the bus representatives. Only needs to be provided when parameter `node_color` is set to 'voltage'. Default: None. limits_cb_lines : :obj:`tuple` Tuple with limits for colorbar of line color. First entry is the minimum and second entry the maximum value. Only needs to be provided when parameter `line_color` is not None. Default: None. limits_cb_nodes : :obj:`tuple` Tuple with limits for colorbar of nodes. First entry is the minimum and second entry the maximum value. Only needs to be provided when parameter `node_color` is not None. Default: None. xlim : :obj:`tuple` Limits of x-axis. Default: None. ylim : :obj:`tuple` Limits of y-axis. Default: None. lines_cmap : :obj:`str` Colormap to use for lines in case `line_color` is 'loading' or 'expansion_costs'. Default: 'inferno_r'. title : :obj:`str` Title of the plot. Default: ''. scaling_factor_line_width : :obj:`float` or None If provided line width is set according to the nominal apparent power of the lines. If line width is None a default line width of 2 is used for each line. Default: None. """ def get_color_and_size(name, colors_dict, sizes_dict): if 'BranchTee' in name: return colors_dict['BranchTee'], sizes_dict['BranchTee'] elif 'LVStation' in name: return colors_dict['LVStation'], sizes_dict['LVStation'] elif 'GeneratorFluctuating' in name: return (colors_dict['GeneratorFluctuating'], sizes_dict['GeneratorFluctuating']) elif 'Generator' in name: return colors_dict['Generator'], sizes_dict['Generator'] elif 'DisconnectingPoint' in name: return (colors_dict['DisconnectingPoint'], sizes_dict['DisconnectingPoint']) elif 'MVStation' in name: return colors_dict['MVStation'], sizes_dict['MVStation'] elif 'Storage' in name: return colors_dict['Storage'], sizes_dict['Storage'] else: return colors_dict['else'], sizes_dict['else'] def nodes_by_technology(buses): bus_sizes = {} bus_colors = {} colors_dict = { 'BranchTee': 'b', 'GeneratorFluctuating': 'g', 'Generator': 'k', 'LVStation': 'c', 'MVStation': 'r', 'Storage': 'y', 'DisconnectingPoint': '0.75', 'else': 'orange' } sizes_dict = { 'BranchTee': 10, 'GeneratorFluctuating': 100, 'Generator': 100, 'LVStation': 50, 'MVStation': 120, 'Storage': 100, 'DisconnectingPoint': 50, 'else': 200 } for bus in buses: bus_colors[bus], bus_sizes[bus] = get_color_and_size( bus, colors_dict, sizes_dict) return bus_sizes, bus_colors def nodes_by_voltage(buses, voltage): bus_colors = {} bus_sizes = {} for bus in buses: if 'primary' in bus: bus_tmp = bus[12:] else: bus_tmp = bus[4:] if timestep is not None: bus_colors[bus] = 100 * abs(1 - voltage.loc[timestep, ('mv', bus_tmp)]) else: bus_colors[bus] = 100 * max( abs(1 - voltage.loc[:, ('mv', bus_tmp)])) bus_sizes[bus] = 50 return bus_sizes, bus_colors def nodes_storage_integration(buses): bus_sizes = {} for bus in buses: if not 'storage' in bus: bus_sizes[bus] = 0 else: tmp = bus.split('_') storage_repr = '_'.join(tmp[1:]) bus_sizes[bus] = pypsa_network.storage_units.loc[ storage_repr, 'p_nom'] * 1000 / 3 return bus_sizes def nodes_by_costs(buses, grid_expansion_costs): # sum costs for each station costs_lv_stations = grid_expansion_costs[ grid_expansion_costs.index.str.contains("LVStation")] costs_lv_stations['station'] = \ costs_lv_stations.reset_index()['index'].apply( lambda _: '_'.join(_.split('_')[0:2])).values costs_lv_stations = costs_lv_stations.groupby('station').sum() costs_mv_station = grid_expansion_costs[ grid_expansion_costs.index.str.contains("MVStation")] costs_mv_station['station'] = \ costs_mv_station.reset_index()['index'].apply( lambda _: '_'.join(_.split('_')[0:2])).values costs_mv_station = costs_mv_station.groupby('station').sum() bus_sizes = {} bus_colors = {} for bus in buses: if 'LVStation' in bus: try: tmp = bus.split('_') lv_st = '_'.join(tmp[2:]) bus_colors[bus] = costs_lv_stations.loc[lv_st, 'total_costs'] bus_sizes[bus] = 100 except: bus_colors[bus] = 0 bus_sizes[bus] = 0 elif 'MVStation' in bus: try: tmp = bus.split('_') mv_st = '_'.join(tmp[2:]) bus_colors[bus] = costs_mv_station.loc[mv_st, 'total_costs'] bus_sizes[bus] = 100 except: bus_colors[bus] = 0 bus_sizes[bus] = 0 else: bus_colors[bus] = 0 bus_sizes[bus] = 0 return bus_sizes, bus_colors # set font and font size font = {'family': 'serif', 'size': 15} matplotlib.rc('font', **font) # create pypsa network only containing MV buses and lines pypsa_plot = PyPSANetwork() pypsa_plot.buses = pypsa_network.buses.loc[pypsa_network.buses.v_nom > 1] # filter buses of aggregated loads and generators pypsa_plot.buses = pypsa_plot.buses[~pypsa_plot.buses.index.str. contains("agg")] pypsa_plot.lines = pypsa_network.lines[pypsa_network.lines.bus0.isin( pypsa_plot.buses.index)][pypsa_network.lines.bus1.isin( pypsa_plot.buses.index)] # line colors if line_color == 'loading': line_colors = tools.calculate_relative_line_load( pypsa_network, configs, line_load, pypsa_network.lines.v_nom, pypsa_plot.lines.index, timestep).max() elif line_color == 'expansion_costs': node_color = 'expansion_costs' line_costs = pypsa_plot.lines.join(grid_expansion_costs, rsuffix='costs', how='left') line_colors = line_costs.total_costs.fillna(0) else: line_colors = pd.Series('black', index=pypsa_plot.lines.index) # bus colors and sizes if node_color == 'technology': bus_sizes, bus_colors = nodes_by_technology(pypsa_plot.buses.index) bus_cmap = None elif node_color == 'voltage': bus_sizes, bus_colors = nodes_by_voltage(pypsa_plot.buses.index, voltage) bus_cmap = plt.cm.Blues elif node_color == 'storage_integration': bus_sizes = nodes_storage_integration(pypsa_plot.buses.index) bus_colors = 'orangered' bus_cmap = None elif node_color == 'expansion_costs': bus_sizes, bus_colors = nodes_by_costs(pypsa_plot.buses.index, grid_expansion_costs) bus_cmap = None elif node_color is None: bus_sizes = 0 bus_colors = 'r' bus_cmap = None else: logging.warning('Choice for `node_color` is not valid. Default is ' 'used instead.') bus_sizes = 0 bus_colors = 'r' bus_cmap = None # convert bus coordinates to Mercator if contextily and background_map: inProj = Proj(init='epsg:4326') outProj = Proj(init='epsg:3857') x2, y2 = transform(inProj, outProj, list(pypsa_plot.buses.loc[:, 'x']), list(pypsa_plot.buses.loc[:, 'y'])) pypsa_plot.buses.loc[:, 'x'] = x2 pypsa_plot.buses.loc[:, 'y'] = y2 # plot plt.figure(figsize=(12, 8)) ax = plt.gca() # plot grid district if grid_district_geom and geopandas: try: subst = pypsa_network.buses[pypsa_network.buses.index.str.contains( "MVStation")].index[0] subst_id = subst.split('_')[-1] projection = 3857 if contextily and background_map else 4326 region = get_grid_district_polygon(configs, subst_id=subst_id, projection=projection) region.plot(ax=ax, color='white', alpha=0.2, edgecolor='red', linewidth=2) except Exception as e: logging.warning("Grid district geometry could not be plotted due " "to the following error: {}".format(e)) # if scaling factor is given s_nom is plotted as line width if scaling_factor_line_width is not None: line_width = pypsa_plot.lines.s_nom * scaling_factor_line_width else: line_width = 2 cmap = plt.cm.get_cmap(lines_cmap) ll = pypsa_plot.plot(line_colors=line_colors, line_cmap=cmap, ax=ax, title=title, line_widths=line_width, branch_components=['Line'], basemap=True, bus_sizes=bus_sizes, bus_colors=bus_colors, bus_cmap=bus_cmap) # color bar line loading if line_color == 'loading': if limits_cb_lines is None: limits_cb_lines = (min(line_colors), max(line_colors)) v = np.linspace(limits_cb_lines[0], limits_cb_lines[1], 101) cb = plt.colorbar(ll[1], boundaries=v, ticks=v[0:101:10]) cb.set_clim(vmin=limits_cb_lines[0], vmax=limits_cb_lines[1]) cb.set_label('Line loading in p.u.') # color bar grid expansion costs elif line_color == 'expansion_costs': if limits_cb_lines is None: limits_cb_lines = (min(min(line_colors), min(bus_colors.values())), max(max(line_colors), max(bus_colors.values()))) v = np.linspace(limits_cb_lines[0], limits_cb_lines[1], 101) cb = plt.colorbar(ll[1], boundaries=v, ticks=v[0:101:10]) cb.set_clim(vmin=limits_cb_lines[0], vmax=limits_cb_lines[1]) cb.set_label('Grid expansion costs in kEUR') # color bar voltage if node_color == 'voltage': if limits_cb_nodes is None: limits_cb_nodes = (min(bus_colors.values()), max(bus_colors.values())) v_voltage = np.linspace(limits_cb_nodes[0], limits_cb_nodes[1], 101) cb_voltage = plt.colorbar(ll[0], boundaries=v_voltage, ticks=v_voltage[0:101:10]) cb_voltage.set_clim(vmin=limits_cb_nodes[0], vmax=limits_cb_nodes[1]) cb_voltage.set_label('Voltage deviation in %') # storages if node_color == 'expansion_costs': ax.scatter(pypsa_plot.buses.loc[pypsa_network.storage_units.loc[:, 'bus'], 'x'], pypsa_plot.buses.loc[pypsa_network.storage_units.loc[:, 'bus'], 'y'], c='orangered', s=pypsa_network.storage_units.loc[:, 'p_nom'] * 1000 / 3) # add legend for storage size and line capacity if (node_color == 'storage_integration' or node_color == 'expansion_costs') and \ pypsa_network.storage_units.loc[:, 'p_nom'].any() > 0: scatter_handle = plt.scatter([], [], c='orangered', s=100, label='= 300 kW battery storage') else: scatter_handle = None if scaling_factor_line_width is not None: line_handle = plt.plot([], [], c='black', linewidth=scaling_factor_line_width * 10, label='= 10 MVA') else: line_handle = None if scatter_handle and line_handle: plt.legend(handles=[scatter_handle, line_handle[0]], labelspacing=1, title='Storage size and line capacity', borderpad=0.5, loc=2, framealpha=0.5, fontsize='medium') elif scatter_handle: plt.legend(handles=[scatter_handle], labelspacing=1, title='Storage size', borderpad=0.5, loc=2, framealpha=0.5, fontsize='medium') elif line_handle: plt.legend(handles=[line_handle[0]], labelspacing=1, title='Line capacity', borderpad=0.5, loc=2, framealpha=0.5, fontsize='medium') # axes limits if xlim is not None: ax.set_xlim(xlim[0], xlim[1]) if ylim is not None: ax.set_ylim(ylim[0], ylim[1]) # hide axes labels ax.get_xaxis().set_visible(False) ax.get_yaxis().set_visible(False) # draw arrows on lines if arrows and timestep and line_color == 'loading': path = ll[1].get_segments() colors = cmap(ll[1].get_array() / 100) for i in range(len(path)): if pypsa_network.lines_t.p0.loc[timestep, line_colors.index[i]] > 0: arrowprops = dict(arrowstyle="->", color='b') #colors[i]) else: arrowprops = dict(arrowstyle="<-", color='b') #colors[i]) ax.annotate("", xy=abs((path[i][0] - path[i][1]) * 0.51 - path[i][0]), xytext=abs((path[i][0] - path[i][1]) * 0.49 - path[i][0]), arrowprops=arrowprops, size=10) # plot map data in background if contextily and background_map: try: add_basemap(ax, zoom=12) except Exception as e: logging.warning("Background map could not be plotted due to the " "following error: {}".format(e)) if filename is None: plt.show() else: plt.savefig(filename) plt.close()
def load_topology(nuts_codes, config, voltages: List[float] = None, plot: bool = False): net = Network() net.import_from_csv_folder( f"{data_path}topologies/pypsa_entsoe_gridkit/generated/base_network/") if 1: import matplotlib.pyplot as plt net.plot(bus_sizes=0.001) plt.show() exit() # Remove all buses outside desired regions region_shapes_ds = get_shapes(nuts_codes, save=True)["geometry"] buses_in_nuts_regions = \ net.buses[['x', 'y']].apply(lambda p: any([shape.contains(Point(p)) for shape in region_shapes_ds]), axis=1) net.buses = net.buses[buses_in_nuts_regions] if 0: plt.figure() net.plot(bus_sizes=0.001) # Remove all buses which are not at the desired voltage buses_with_v_nom_to_keep_b = net.buses.v_nom.isnull() if voltages is not None: buses_with_v_nom_to_keep_b |= net.buses.v_nom.isin(voltages) logger.info( "Removing buses with voltages {}" "".format( pd.Index( net.buses.v_nom.unique()).dropna().difference(voltages))) net.buses = net.buses[buses_with_v_nom_to_keep_b] if 1: plt.figure() net.plot(bus_sizes=0.001) plt.show() # Remove dangling branches net.lines = remove_dangling_branches(net.lines, net.buses.index) net.links = remove_dangling_branches(net.links, net.buses.index) net.transformers = remove_dangling_branches(net.transformers, net.buses.index) # Set electrical parameters set_electrical_parameters_lines(net, config['lines'], net.buses.v_nom.dropna().unique().tolist()) set_electrical_parameters_links(net, config['links']) set_electrical_parameters_transformers(net, config['transformers']) # Allows to set under construction links and lines to 0 or remove them completely, # and remove some unconnected components that might appear as a result net = adjust_capacities_of_under_construction_branches( net, config['lines'], config['links']) # Remove unconnected components # TODO: allow to simplify the network (i.e. convert everything to 380) or not ? net = cluster_network(net, nuts_codes) if plot: from epippy.topologies.core.plot import plot_topology all_lines = pd.concat( (net.links[['bus0', 'bus1']], net.lines[['bus0', 'bus1']])) plot_topology(net.buses, all_lines) plt.show() return net
def cluster_network(net: pypsa.Network, nuts_codes: List[str]): # Get shapes of regions (onshore and offshore) shapes = get_shapes(nuts_codes, which='onshore') # --- Buses --- # # TODO: this is shit --> should return a list keeping the index of the original points def add_region(lon, lat): try: region_code = matched_locs[lon, lat] # Need the if because some points are exactly at the same position return region_code if (isinstance(region_code, str) or isinstance(region_code, float) or isinstance(region_code, int)) else region_code.iloc[0] except (AttributeError, KeyError): return None # plants_region_ds.loc[pp_df_in_country.index] = \ # pp_df_in_country[["lon", "lat"]].apply(lambda x: add_region(x[0], x[1]), axis=1) buses_positions = net.buses[['x', 'y']].apply(lambda p: (p.x, p.y), axis=1).tolist() matched_locs = match_points_to_regions(buses_positions, shapes["geometry"], keep_outside=False).dropna() old_to_new_buses_map = net.buses[["x", "y"]].apply(lambda p: add_region(p.x, p.y), axis=1).dropna() nuts_codes_with_buses = list(set(old_to_new_buses_map)) # Merge buses to centroid of countries (even offshore ones) buses_df = pd.DataFrame(columns=["bus_id", "x", "y"]) buses_df["bus_id"] = nuts_codes_with_buses buses_df = buses_df.set_index('bus_id') regions = shapes.loc[nuts_codes_with_buses, 'geometry'] buses_df['onshore_region'] = regions buses_df["x"] = regions.centroid.apply(lambda p: p.x) buses_df["y"] = regions.centroid.apply(lambda p: p.y) print(buses_df) def compute_distance(bus0, bus1): bus0_x, bus0_y = buses_df.loc[bus0, ['x', 'y']] bus1_x, bus1_y = buses_df.loc[bus1, ['x', 'y']] return geopy.distance.geodesic((bus0_y, bus0_x), (bus1_y, bus1_x)).km # --- Lines --- # # Remove lines associated to buses that have been removed net.lines = net.lines.loc[net.lines.bus0.isin(old_to_new_buses_map.index) & net.lines.bus1.isin(old_to_new_buses_map.index)] # Assign new bus to lines net.lines.bus0 = net.lines.bus0.map(old_to_new_buses_map) net.lines.bus1 = net.lines.bus1.map(old_to_new_buses_map) # Remove lines that have the same starting and end bus net.lines = net.lines[net.lines.bus0 != net.lines.bus1] # Merge lines with same starting and end bus # Get all lines connected to the same bus in the same direction net.lines[['bus0', 'bus1']] = [sorted((bus0, bus1)) for (bus0, bus1) in net.lines[['bus0', 'bus1']].values] # Get pairs of connected bus connected_buses = set([(bus0, bus1) for (bus0, bus1) in net.lines[['bus0', 'bus1']].values.tolist()]) all_lines_df = pd.DataFrame() # Compute reactance of lines net.lines["x"] = net.lines["length"]*net.lines['type'].map(net.line_types.x_per_length) for bus0, bus1 in connected_buses: # line_index = f"{bus0}-{bus1}" # lines_df.loc[line_index, ['bus0', 'bus1']] = (bus0, bus1) # Get all lines in original network that were connected to bus0 and bus1 old_lines_df = net.lines[(net.lines.bus0 == bus0) & (net.lines.bus1 == bus1)] # Merge those lines by voltage level # TODO: Parameters to consider # - name: ok # - bus0: ok # - bus1: ok # - underground: ? --> affect cost # - under_construction: already dealt with before # - tags: nope # - geometry: nope (replaced by straight line) # - length: how? -> just take fly-distance (times 1.25 like in pypsa-eur?) # - x: how? # - v_nom: how? # - num_parallel: how? # Use sth similar to this: net.lines.loc[non380_lines_b, 'num_parallel'] *= (net.lines.loc[non380_lines_b, 'v_nom'] / 380.)**2 ? # - s_nom: how? lines_df = old_lines_df.groupby("type")['v_nom'].unique().apply(lambda x: x[0]).to_frame() lines_df['s_nom'] = old_lines_df.groupby("type")['s_nom'].sum() lines_df['num_parallel'] = old_lines_df.groupby("type")['num_parallel'].sum() lines_df['x'] = old_lines_df.groupby("type")['x'].apply(lambda x: 1/sum(1/x)) lines_df["line_id"] = lines_df['v_nom'].apply(lambda x: f"{bus0}-{bus1}-{x}") lines_df["bus0"] = bus0 lines_df["bus1"] = bus1 lines_df["length"] = lines_df[['bus0', 'bus1']].apply(lambda x: compute_distance(x.bus0, x.bus1), axis=1) lines_df = lines_df.reset_index().set_index('line_id') all_lines_df = pd.concat((all_lines_df, lines_df)) print(all_lines_df) # --- Links --- # # Remove lines associated to buses that have been removed net.links = net.links.loc[net.links.bus0.isin(old_to_new_buses_map.index) & net.links.bus1.isin(old_to_new_buses_map.index)] net.links.bus0 = net.links.bus0.map(old_to_new_buses_map) net.links.bus1 = net.links.bus1.map(old_to_new_buses_map) # Remove links that have the same starting and end bus net.links = net.links[net.links.bus0 != net.links.bus1] # Merge links with same starting and end bus # Get all links connected to the same bus in the same direction net.links[['bus0', 'bus1']] = [sorted((str(bus0), str(bus1))) for (bus0, bus1) in net.links[['bus0', 'bus1']].values] # Get pairs of connected bus connected_buses = set([(bus0, bus1) for (bus0, bus1) in net.links[['bus0', 'bus1']].values.tolist()]) links_df = pd.DataFrame(index=[f"{bus0}-{bus1}" for (bus0, bus1) in connected_buses], columns=['bus0', 'bus1', 'p_nom', 'length']) for bus0, bus1 in connected_buses: link_index = f"{bus0}-{bus1}" links_df.loc[link_index, ['bus0', 'bus1']] = (bus0, bus1) # Get all links in original network that were connected to bus0 and bus1 old_links_df = net.links[(net.links.bus0 == bus0) & (net.links.bus1 == bus1)] # Add capacities links_df.loc[link_index, 'p_nom'] = old_links_df['p_nom'].sum() links_df["length"] = links_df[['bus0', 'bus1']].apply(lambda x: compute_distance(x.bus0, x.bus1), axis=1) # TODO: Parameters to consider # - name: ok # - bus0: ok # - bus1: ok # - carrier: nope --> all dc # - underground: how? --> affect cost # - underwater_fraction: how? --> affect cost # - under_construction: already dealt with before # - tags: nope # - geometry: nope (replaced by straight line) # - length: how? -> just take fly-distance (times 1.25 like in pypsa-eur?) # - p_nom: how? # - num_parallel: how? # TODO: What about transformers? # print(net.links) print(links_df) clustered_net = pypsa.Network() clustered_net.import_components_from_dataframe(buses_df, 'Bus') clustered_net.import_components_from_dataframe(all_lines_df, 'Line') clustered_net.import_components_from_dataframe(links_df, 'Link') print(clustered_net.buses.onshore_region) return clustered_net