diff --git a/edisgo/flex_opt/costs.py b/edisgo/flex_opt/costs.py index 7cbe359b1..1f582354a 100644 --- a/edisgo/flex_opt/costs.py +++ b/edisgo/flex_opt/costs.py @@ -372,3 +372,203 @@ def transformer_expansion_costs(edisgo_obj, transformer_names=None): ] ) return costs_df + + +def grid_expansion_costs_from_diff(edisgo_obj): + """ + Returns grid expansion costs based on a comparison of the original grid topology + and the current grid topology. + + The original grid topology is stored in :class:`~.EDisGo` + + Parameters + ---------- + + Returns + -------- + `pandas.DataFrame` + + """ + edisgo_grid_orig = edisgo_obj.topology.original_grid_topology + + buses_orig = edisgo_grid_orig.topology.buses_df.copy() + lines_orig = edisgo_grid_orig.topology.lines_df.copy() + + buses = edisgo_obj.topology.buses_df.copy() + lines = edisgo_obj.topology.lines_df.copy() + + # get lines that are in grid now but not in original grid - these were either + # added or changed + new_or_changed_or_split_lines = [_ for _ in lines.index if + _ not in lines_orig.index] + # get lines that were in original grid but not in grid now - these were split or + # connected to other bus (when splitting LV grid lines where feeder is split are + # renamed) or removed when component was deleted, e.g. generator decommissioned + removed_lines = [_ for _ in lines_orig.index if _ not in lines.index] + # get lines that are in both grids to check, whether they changed + lines_in_both_grids = [_ for _ in lines_orig.index if _ in lines.index] + + # get new buses + new_buses = [_ for _ in buses.index if _ not in buses_orig.index] + # get removed buses - exist when generators were decommisioned or lines aggregated + removed_buses = [_ for _ in buses_orig.index if _ not in buses.index] + + # track lines changes + lines_changed = pd.DataFrame() + + # check lines in both grids whether they changed - check line type and length and + # add to lines_changed if either changed + lines_tmp = lines.loc[lines_in_both_grids, :] + lines_changed_length = lines_tmp[ + lines_orig.loc[lines_in_both_grids, "length"] != lines.loc[ + lines_in_both_grids, "length"]] + if not lines_changed_length.empty: + lines_changed = pd.concat([lines_changed, lines_changed_length], axis=0) + lines_changed_type = lines_tmp[ + lines_orig.loc[lines_in_both_grids, "type_info"] != lines.loc[ + lines_in_both_grids, "type_info"]] + if not lines_changed_type.empty: + lines_changed = pd.concat([lines_changed, lines_changed_type], axis=0) + + # check removed lines + for removed_line in removed_lines.copy(): + # check whether any of its buses were also removed + if (lines_orig.at[removed_line, "bus0"] in removed_buses) or ( + lines_orig.at[removed_line, "bus1"] in removed_buses): + # drop from removed lines + removed_lines.remove(removed_line) + + # remaining lines in removed_lines list should be lines that were split + # match each line in removed_lines with lines in new_or_changed_or_split_lines - + # important because these may not lead to additional costs + for removed_line in removed_lines: + # find path between original buses in new grid - all lines on that path + line_bus0 = lines_orig.at[removed_line, "bus0"] + line_bus1 = lines_orig.at[removed_line, "bus1"] + if buses.at[line_bus0, "v_nom"] > 1.0: + graph = edisgo_obj.topology.mv_grid.graph + else: + # check if buses are in same LV grid or not (could happen when grid is + # split) + if int(buses.at[line_bus0, "lv_grid_id"]) == int( + buses.at[line_bus1, "lv_grid_id"]): + graph = edisgo_obj.topology.get_lv_grid( + int(buses.at[line_bus0, "lv_grid_id"])).graph + else: + graph = edisgo_obj.topology.to_graph() + path = nx.shortest_path(graph, line_bus0, line_bus1) + # get lines in path + lines_in_path = lines[lines.bus0.isin(path) & lines.bus1.isin(path)] + # drop these lines from new_or_changed_or_split_lines + for l in lines_in_path.index: + try: + new_or_changed_or_split_lines.remove(l) + except: + logger.debug(f"Line {l} is in path but could not be removed.") + # check whether line type changed or number of parallel lines and add + # to lines_changed + lines_changed_type = lines_in_path[ + lines_in_path.type_info != lines_orig.at[removed_line, "type_info"]] + if not lines_changed_type.empty: + # add to lines_changed dataframe + lines_changed = pd.concat([lines_changed, lines_changed_type], axis=0) + # drop from lines_in_path + lines_in_path.drop(index=lines_changed_type.index, inplace=True) + # for num parallel changes only consider additional line in costs + lines_changed_num_parallel = lines_in_path[ + lines_in_path.num_parallel != lines_orig.at[removed_line, "num_parallel"]] + if not lines_changed_num_parallel.empty: + # reduce num_parallel by number of parallel lines in original grid + lines_changed_num_parallel["num_parallel"] = lines_changed_num_parallel[ + "num_parallel"] - lines_orig.at[ + removed_line, "num_parallel"] + lines_changed = pd.concat( + [lines_changed, lines_changed_num_parallel], axis=0 + ) + + # get new buses where new component is directly connected - these are most likely + # not new branch tees where line was split + buses_components_orig = pd.concat( + [edisgo_grid_orig.topology.loads_df.loc[:, ["bus"]], + edisgo_grid_orig.topology.generators_df.loc[:, ["bus"]], + edisgo_grid_orig.topology.storage_units_df.loc[:, ["bus"]]] + ) + buses_components = pd.concat( + [edisgo_obj.topology.loads_df.loc[:, ["bus"]], + edisgo_obj.topology.generators_df.loc[:, ["bus"]], + edisgo_obj.topology.storage_units_df.loc[:, ["bus"]]] + ) + buses_components_new = list(set([_ for _ in buses_components.bus if + _ not in buses_components_orig.bus and _ in + new_buses])) + new_or_changed_or_split_lines_df = lines.loc[new_or_changed_or_split_lines, :] + added_lines = new_or_changed_or_split_lines_df[ + (new_or_changed_or_split_lines_df.bus0.isin(buses_components_new)) | ( + new_or_changed_or_split_lines_df.bus1.isin(buses_components_new))] + lines_changed = pd.concat([lines_changed, added_lines], axis=0) + + # remove from new_or_changed_or_split_lines + for l in new_or_changed_or_split_lines_df.index: + new_or_changed_or_split_lines.remove(l) + + if not len(new_or_changed_or_split_lines) == 0: + logger.warning( + f"new_or_changed_or_split_lines is not empty: " + f"{new_or_changed_or_split_lines}" + ) + + # determine line costs + lines_changed.drop_duplicates(keep="last", inplace=True, subset=["bus0", "bus1"]) + line_costs = line_expansion_costs(edisgo_obj, lines_names=lines_changed.index) + costs_df = pd.DataFrame( + { + "type": lines_changed.type_info, + "total_costs": (line_costs.costs_earthworks + + line_costs.costs_cable * lines_changed.num_parallel), + "length": lines_changed.num_parallel * lines_changed.length, + "quantity": lines_changed.num_parallel, + "voltage_level": line_costs.voltage_level, + }, + index=lines_changed.index + ) + + # add costs for transformers + transformers_orig = pd.concat( + [edisgo_grid_orig.topology.transformers_df.copy(), + edisgo_grid_orig.topology.transformers_hvmv_df.copy()] + ) + transformers = pd.concat( + [edisgo_obj.topology.transformers_df.copy(), + edisgo_obj.topology.transformers_hvmv_df.copy()] + ) + new_transformers = [_ for _ in transformers.index if + _ not in transformers_orig.index] + transformers_in_both_grids = [_ for _ in transformers_orig.index if + _ in transformers.index] + transformers_changed = transformers.loc[new_transformers, :] + # check transformers in both grids whether they changed - check type_info + # and add to transformers_changed if type changed + transformers_tmp = transformers.loc[transformers_in_both_grids, :] + transformers_changed_type = transformers_tmp[ + transformers_orig.loc[transformers_in_both_grids, "type_info"] != + transformers.loc[ + transformers_in_both_grids, "type_info"]] + if not transformers_changed_type.empty: + transformers_changed = pd.concat( + [transformers_changed, transformers_changed_type], axis=0) + transformer_costs = transformer_expansion_costs( + edisgo_obj, transformers_changed.index + ) + transformer_costs_df = pd.DataFrame( + { + "type": transformers_changed.type_info, + "total_costs": transformer_costs.costs, + "length": 0.0, + "quantity": 1, + "voltage_level": transformer_costs.voltage_level, + }, + index=transformers_changed.index + ) + costs_df = pd.concat([costs_df, transformer_costs_df]) + + return lines_changed, transformers_changed, costs_df diff --git a/edisgo/io/ding0_import.py b/edisgo/io/ding0_import.py index 326553111..8f1b9e08e 100644 --- a/edisgo/io/ding0_import.py +++ b/edisgo/io/ding0_import.py @@ -132,3 +132,6 @@ def sort_hvmv_transformer_buses(transformers_df): # check data integrity edisgo_obj.topology.check_integrity() + + # write topology to Topology.original_grid_topology to save original grid topology + edisgo_obj.topology.original_grid_topology = edisgo_obj.topology diff --git a/edisgo/network/topology.py b/edisgo/network/topology.py index f411bf312..b8cb70fb2 100755 --- a/edisgo/network/topology.py +++ b/edisgo/network/topology.py @@ -1,5 +1,6 @@ from __future__ import annotations +import copy import logging import os import random @@ -97,6 +98,7 @@ class Topology: def __init__(self, **kwargs): # load technical data of equipment self._equipment_data = self._load_equipment_data(kwargs.get("config", None)) + self._original_grid_topology = None @staticmethod def _load_equipment_data(config=None): @@ -636,15 +638,10 @@ def charging_points_df(self): """ Returns a subset of :py:attr:`~loads_df` containing only charging points. - Parameters - ---------- - type : str - Load type. Default: "charging_point" - Returns ------- :pandas:`pandas.DataFrame` - Pandas DataFrame with all loads of the given type. + DataFrame with all chargings points in the grid. """ if "charging_point" in self.loads_df.type.unique(): @@ -852,6 +849,37 @@ def equipment_data(self): """ return self._equipment_data + @property + def original_grid_topology(self): + """ + Network topology before components are added or removed and grid is reinforced. + + This is set up when the ding0 grid is loaded. + + Parameters + ---------- + :py:class:`~.network.topology.Topology` + Topology class with original grid topology data. + + Returns + -------- + :py:class:`~.network.topology.Topology` + + """ + return self._original_grid_topology + + @original_grid_topology.setter + def original_grid_topology(self, topo): + if topo is not None: + # deepcopy is used so that in case topology object is changed the original + # topology is not changed + topo = copy.deepcopy(topo) + # make sure the original topology is set to None, to avoid recursive + # behavior when topology object is written to csv + if topo._original_grid_topology is not None: + topo._original_grid_topology = None + self._original_grid_topology = topo + def get_connected_lines_from_bus(self, bus_name): """ Returns all lines connected to specified bus. @@ -1973,7 +2001,7 @@ def connect_to_mv(self, edisgo_object, comp_data, comp_type="generator"): # object) comp_connected = False for dist_min_obj in conn_objects_min_stack: - # do not allow connection to virtual busses + # do not allow connection to virtual buses if "virtual" not in dist_min_obj["repr"]: line_type, num_parallel = select_cable(edisgo_object, "mv", power) target_obj_result = self._connect_mv_bus_to_target_object( @@ -2555,10 +2583,6 @@ def _connect_mv_bus_to_target_object( # switch data if switch_bus and switch_bus == line_data.bus0: self.switches_df.loc[switch_data.name, "branch"] = line_name_bus0 - # add line to equipment changes - edisgo_object.results._add_line_to_equipment_changes( - line=self.lines_df.loc[line_name_bus0, :], - ) # add new line between newly created branch tee and line's bus0 line_length = geo.calc_geo_dist_vincenty( @@ -2584,10 +2608,6 @@ def _connect_mv_bus_to_target_object( # switch data if switch_bus and switch_bus == line_data.bus1: self.switches_df.loc[switch_data.name, "branch"] = line_name_bus1 - # add line to equipment changes - edisgo_object.results._add_line_to_equipment_changes( - line=self.lines_df.loc[line_name_bus1, :], - ) # add new line for new bus line_length = geo.calc_geo_dist_vincenty( @@ -2850,6 +2870,39 @@ def to_csv(self, directory): axis=1, ).to_csv(os.path.join(directory, "network.csv")) + # original network + if self.original_grid_topology is not None: + self.original_grid_topology.to_csv( + os.path.join(directory, "original_grid_topology") + ) + + def _get_matching_dict_of_attributes_and_file_names(self): + """ + Helper function that matches attribute names to file names. + + Is used in function :attr:`~.network.topology.TopologyBase.from_csv` to set + which attribute of :class:`~.network.topology.TopologyBase` is saved under + which file name. + + Returns + ------- + dict + Dictionary matching attribute names and file names with attribute + names as keys and corresponding file names as values. + + """ + return { + "buses_df": "buses.csv", + "lines_df": "lines.csv", + "loads_df": "loads.csv", + "generators_df": "generators.csv", + "storage_units_df": "storage_units.csv", + "transformers_df": "transformers.csv", + "transformers_hvmv_df": "transformers_hvmv.csv", + "switches_df": "switches.csv", + "network": "network.csv", + } + def from_csv(self, data_path, edisgo_obj, from_zip_archive=False): """ Restores topology from csv files. @@ -2864,36 +2917,69 @@ def from_csv(self, data_path, edisgo_obj, from_zip_archive=False): """ - def _get_matching_dict_of_attributes_and_file_names(): + def _set_data(attrs_to_set, set_obj): """ - Helper function that matches attribute names to file names. - - Is used in function :attr:`~.network.topology.Topology.from_csv` to set - which attribute of :class:`~.network.topology.Topology` is saved under - which file name. + Sets topology attributes from csv files. - Returns - ------- - dict - Dictionary matching attribute names and file names with attribute - names as keys and corresponding file names as values. + Parameters + ---------- + attrs_to_set : dict + Dictionary with attributes to set in the form as returned by + _get_matching_dict_of_attributes_and_file_names(). + set_obj : Topology + Topology object on which to set the data, as data can be set to the + Topology object in Topology.original_grid_data as well. """ - return { - "buses_df": "buses.csv", - "lines_df": "lines.csv", - "loads_df": "loads.csv", - "generators_df": "generators.csv", - "charging_points_df": "charging_points.csv", - "storage_units_df": "storage_units.csv", - "transformers_df": "transformers.csv", - "transformers_hvmv_df": "transformers_hvmv.csv", - "switches_df": "switches.csv", - "network": "network.csv", - } + for attr, file in attrs_to_set.items(): + if from_zip_archive: + # open zip file to make it readable for pandas + with zip.open(file) as f: + df = pd.read_csv(f, index_col=0) + else: + path = os.path.join(data_path, file) + df = pd.read_csv(path, index_col=0) + + if attr == "generators_df": + # delete slack if it was included + df = df.loc[df.control != "Slack"] + elif "transformers" in attr: + # rename columns to match convention + df = df.rename(columns={"x": "x_pu", "r": "r_pu"}) + elif attr == "network": + # rename columns to match convention + df = df.rename( + columns={ + "mv_grid_district_geom": "geom", + "mv_grid_district_population": "population", + } + ) + + # set grid district information + setattr( + set_obj, + "grid_district", + { + "population": df.population.iat[0], + "geom": wkt_loads(df.geom.iat[0]), + "srid": df.srid.iat[0], + }, + ) + + # set up medium voltage grid + setattr( + set_obj, + "mv_grid", + MVGrid(edisgo_obj=edisgo_obj, id=df.index[0]), + ) + + continue + + # set attribute + setattr(set_obj, attr, df) # get all attributes and corresponding file names - attrs = _get_matching_dict_of_attributes_and_file_names() + attrs = self._get_matching_dict_of_attributes_and_file_names() if from_zip_archive: # read from zip archive @@ -2912,49 +2998,29 @@ def _get_matching_dict_of_attributes_and_file_names(): files = os.listdir(data_path) attrs_to_read = {k: v for k, v in attrs.items() if v in files} + _set_data(attrs_to_read, self) - for attr, file in attrs_to_read.items(): - if from_zip_archive: - # open zip file to make it readable for pandas - with zip.open(file) as f: - df = pd.read_csv(f, index_col=0) + # read original grid topology data + attrs = self._get_matching_dict_of_attributes_and_file_names() + if from_zip_archive: + # add directory to attributes to match zip archive + attrs = { + k: f"topology/original_grid_topology/{v}" for k, v in attrs.items() + } + attrs_to_read = {k: v for k, v in attrs.items() if v in files} + else: + if "original_grid_topology" in files: + files = os.listdir(os.path.join(data_path, "original_grid_topology")) + attrs_to_read = { + k: f"original_grid_topology/{v}" + for k, v in attrs.items() + if v in files + } else: - path = os.path.join(data_path, file) - df = pd.read_csv(path, index_col=0) - - if attr == "generators_df": - # delete slack if it was included - df = df.loc[df.control != "Slack"] - elif "transformers" in attr: - # rename columns to match convention - df = df.rename(columns={"x": "x_pu", "r": "r_pu"}) - elif attr == "network": - # rename columns to match convention - df = df.rename( - columns={ - "mv_grid_district_geom": "geom", - "mv_grid_district_population": "population", - } - ) - - # set grid district information - setattr( - self, - "grid_district", - { - "population": df.population.iat[0], - "geom": wkt_loads(df.geom.iat[0]), - "srid": df.srid.iat[0], - }, - ) - - # set up medium voltage grid - setattr(self, "mv_grid", MVGrid(edisgo_obj=edisgo_obj, id=df.index[0])) - - continue - - # set attribute - setattr(self, attr, df) + attrs_to_read = {} + if attrs_to_read: + self.original_grid_topology = Topology() + _set_data(attrs_to_read, self.original_grid_topology) if from_zip_archive: # make sure to destroy ZipFile Class to close any open connections diff --git a/edisgo/tools/plots.py b/edisgo/tools/plots.py index 381c73754..771cdb781 100644 --- a/edisgo/tools/plots.py +++ b/edisgo/tools/plots.py @@ -6,7 +6,6 @@ from typing import TYPE_CHECKING import matplotlib -import matplotlib.cm as cm import numpy as np import pandas as pd import plotly.graph_objects as go @@ -888,7 +887,7 @@ def color_map_color( """ norm = matplotlib.colors.Normalize(vmin=vmin, vmax=vmax) if isinstance(cmap_name, str): - cmap = cm.get_cmap(cmap_name) + cmap = matplotlib.pyplot.colormaps.get_cmap(cmap_name) else: cmap = matplotlib.colors.LinearSegmentedColormap.from_list("mycmap", cmap_name) rgb = cmap(norm(abs(value)))[:3] diff --git a/tests/io/test_ding0_import.py b/tests/io/test_ding0_import.py index 11c77436c..7a1e04bc4 100644 --- a/tests/io/test_ding0_import.py +++ b/tests/io/test_ding0_import.py @@ -36,6 +36,13 @@ def test_import_ding0_grid(self): lv_grid = self.topology.get_lv_grid(3) assert len(lv_grid.buses_df) == 9 + # original topology + assert self.topology.original_grid_topology.buses_df.shape[0] == 142 + # check that original topology does not change when Topology object is changed + self.topology.add_generator("Bus_GeneratorFluctuating_2", 3.0, "solar") + assert self.topology.original_grid_topology.generators_df.shape[0] == 28 + assert self.topology.original_grid_topology.original_grid_topology is None + def test_path_error(self): """Test catching error when path to network does not exist.""" msg = "Directory wrong_directory does not exist." diff --git a/tests/network/test_topology.py b/tests/network/test_topology.py index 0baf02f34..a52139cc5 100644 --- a/tests/network/test_topology.py +++ b/tests/network/test_topology.py @@ -867,8 +867,15 @@ def test_to_csv(self): self.topology.to_csv(dir) saved_files = os.listdir(dir) - assert len(saved_files) == 9 + assert len(saved_files) == 10 assert "generators.csv" in saved_files + assert "network.csv" in saved_files + + # check original network + saved_files = os.listdir(os.path.join(dir, "original_grid_topology")) + assert len(saved_files) == 9 + assert "buses.csv" in saved_files + assert "lines.csv" in saved_files shutil.rmtree(dir) diff --git a/tests/test_edisgo.py b/tests/test_edisgo.py index e0379bd59..fe4f1766e 100755 --- a/tests/test_edisgo.py +++ b/tests/test_edisgo.py @@ -1964,6 +1964,11 @@ def test_import_edisgo_from_files(self): data={"load_1": [5.0, 6.0], "load_2": [7.0, 8.0]}, index=edisgo_obj.timeseries.timeindex[0:2], ) + # manipulate original grid topology, to check whether it is retrieved correctly + # or if the actual topology is used + edisgo_obj.topology.original_grid_topology.remove_load( + "Load_agricultural_LVGrid_3_1" + ) # ################ test with non-existing path ###################### @@ -1989,6 +1994,9 @@ def test_import_edisgo_from_files(self): edisgo_obj.topology.loads_df, check_dtype=False, ) + # check original grid data + assert len(edisgo_obj_loaded.topology.original_grid_topology.buses_df) == 141 + assert len(edisgo_obj_loaded.topology.original_grid_topology.loads_df) == 49 # check time series assert edisgo_obj_loaded.timeseries.timeindex.empty # check configs @@ -1997,7 +2005,17 @@ def test_import_edisgo_from_files(self): assert edisgo_obj_loaded.results.i_res.empty # ############ test with loading other data ########### - + # delete directory + shutil.rmtree(save_dir) + edisgo_obj.topology.original_grid_topology = None + edisgo_obj.save( + save_dir, + save_results=False, + save_electromobility=True, + save_heatpump=True, + save_overlying_grid=True, + save_dsm=True, + ) edisgo_obj_loaded = import_edisgo_from_files( save_dir, import_electromobility=True, @@ -2098,3 +2116,23 @@ def test_import_edisgo_from_files(self): # delete zip file os.remove(zip_file) + + # check zipping original grid data + edisgo_obj = EDisGo(ding0_grid=pytest.ding0_test_network_path) + # manipulate original grid topology, to check whether it is retrieved correctly + # or if the actual topology is used + edisgo_obj.topology.original_grid_topology.remove_load( + "Load_agricultural_LVGrid_3_1" + ) + edisgo_obj.save( + save_dir, + archive=True, + ) + zip_file = f"{save_dir}.zip" + edisgo_obj_loaded = import_edisgo_from_files( + zip_file, + from_zip_archive=True, + ) + # check original grid data + assert len(edisgo_obj_loaded.topology.original_grid_topology.buses_df) == 141 + assert len(edisgo_obj_loaded.topology.original_grid_topology.loads_df) == 49