diff --git a/doc/whatsnew/v0-3-0.rst b/doc/whatsnew/v0-3-0.rst index 73454c34..0165a76f 100644 --- a/doc/whatsnew/v0-3-0.rst +++ b/doc/whatsnew/v0-3-0.rst @@ -22,3 +22,4 @@ Changes * Added method to aggregate LV grid buses to station bus secondary side `#353 `_ * Adapted codebase to work with pandas 2.0 `#373 `_ * Added option to run reinforcement with reduced number of time steps `#379 `_ +* Added a new reinforcement method that separate lv grids when the overloading is very high `#380 `_ diff --git a/edisgo/edisgo.py b/edisgo/edisgo.py index 700153bc..c1af5773 100755 --- a/edisgo/edisgo.py +++ b/edisgo/edisgo.py @@ -969,7 +969,9 @@ def analyze( -------- tuple(:pandas:`pandas.DatetimeIndex`,\ :pandas:`pandas.DatetimeIndex`) - Returns the time steps for which power flow analysis did not converge. + First index contains time steps for which power flow analysis did converge. + Second index contains time steps for which power flow analysis did not + converge. References -------- @@ -1261,12 +1263,15 @@ def reinforce( run_analyze_at_the_end = False logger.info(f"Run the following reinforcements: {setting_list=}") + for setting in setting_list: logger.info(f"Run the following reinforcement: {setting=}") - if not catch_convergence_problems: - func = reinforce_grid - else: - func = catch_convergence_reinforce_grid + func = ( + catch_convergence_reinforce_grid + if catch_convergence_problems + else reinforce_grid + ) + func( edisgo_obj, max_while_iterations=max_while_iterations, @@ -1275,14 +1280,17 @@ def reinforce( n_minus_one=n_minus_one, **setting, ) + if run_analyze_at_the_end: lv_grid_id = kwargs.get("lv_grid_id", None) + if mode == "lv" and lv_grid_id: analyze_mode = "lv" elif mode == "lv": analyze_mode = None else: analyze_mode = mode + edisgo_obj.analyze( mode=analyze_mode, lv_grid_id=lv_grid_id, timesteps=timesteps_pfa ) diff --git a/edisgo/flex_opt/reinforce_grid.py b/edisgo/flex_opt/reinforce_grid.py index b3f06adb..c5f17ea7 100644 --- a/edisgo/flex_opt/reinforce_grid.py +++ b/edisgo/flex_opt/reinforce_grid.py @@ -6,11 +6,13 @@ from typing import TYPE_CHECKING +import numpy as np import pandas as pd from edisgo.flex_opt import check_tech_constraints as checks from edisgo.flex_opt import exceptions, reinforce_measures from edisgo.flex_opt.costs import grid_expansion_costs +from edisgo.flex_opt.reinforce_measures import separate_lv_grid from edisgo.tools import tools from edisgo.tools.temporal_complexity_reduction import get_most_critical_time_steps @@ -113,42 +115,6 @@ def reinforce_grid( reinforcement is conducted. """ - - def _add_lines_changes_to_equipment_changes(): - edisgo.results.equipment_changes = pd.concat( - [ - edisgo.results.equipment_changes, - pd.DataFrame( - { - "iteration_step": [iteration_step] * len(lines_changes), - "change": ["changed"] * len(lines_changes), - "equipment": edisgo.topology.lines_df.loc[ - lines_changes.keys(), "type_info" - ].values, - "quantity": [_ for _ in lines_changes.values()], - }, - index=lines_changes.keys(), - ), - ], - ) - - def _add_transformer_changes_to_equipment_changes(mode: str | None): - df_list = [edisgo.results.equipment_changes] - df_list.extend( - pd.DataFrame( - { - "iteration_step": [iteration_step] * len(transformer_list), - "change": [mode] * len(transformer_list), - "equipment": transformer_list, - "quantity": [1] * len(transformer_list), - }, - index=[station] * len(transformer_list), - ) - for station, transformer_list in transformer_changes[mode].items() - ) - - edisgo.results.equipment_changes = pd.concat(df_list) - if n_minus_one is True: raise NotImplementedError("n-1 security can currently not be checked.") @@ -244,7 +210,6 @@ def _add_transformer_changes_to_equipment_changes(mode: str | None): or not overloaded_lv_stations.empty or not crit_lines.empty ) and while_counter < max_while_iterations: - if not overloaded_mv_station.empty: # reinforce substations transformer_changes = ( @@ -253,8 +218,12 @@ def _add_transformer_changes_to_equipment_changes(mode: str | None): ) ) # write added and removed transformers to results.equipment_changes - _add_transformer_changes_to_equipment_changes("added") - _add_transformer_changes_to_equipment_changes("removed") + _add_transformer_changes_to_equipment_changes( + edisgo, transformer_changes, iteration_step, "added" + ) + _add_transformer_changes_to_equipment_changes( + edisgo, transformer_changes, iteration_step, "removed" + ) if not overloaded_lv_stations.empty: # reinforce distribution substations @@ -264,8 +233,12 @@ def _add_transformer_changes_to_equipment_changes(mode: str | None): ) ) # write added and removed transformers to results.equipment_changes - _add_transformer_changes_to_equipment_changes("added") - _add_transformer_changes_to_equipment_changes("removed") + _add_transformer_changes_to_equipment_changes( + edisgo, transformer_changes, iteration_step, "added" + ) + _add_transformer_changes_to_equipment_changes( + edisgo, transformer_changes, iteration_step, "removed" + ) if not crit_lines.empty: # reinforce lines @@ -273,7 +246,9 @@ def _add_transformer_changes_to_equipment_changes(mode: str | None): edisgo, crit_lines ) # write changed lines to results.equipment_changes - _add_lines_changes_to_equipment_changes() + _add_lines_changes_to_equipment_changes( + edisgo, lines_changes, iteration_step + ) # run power flow analysis again (after updating pypsa object) and check # if all over-loading problems were solved @@ -351,7 +326,6 @@ def _add_transformer_changes_to_equipment_changes(mode: str | None): while_counter = 0 while not crit_nodes.empty and while_counter < max_while_iterations: - # reinforce lines lines_changes = reinforce_measures.reinforce_lines_voltage_issues( edisgo, @@ -359,7 +333,7 @@ def _add_transformer_changes_to_equipment_changes(mode: str | None): crit_nodes, ) # write changed lines to results.equipment_changes - _add_lines_changes_to_equipment_changes() + _add_lines_changes_to_equipment_changes(edisgo, lines_changes, iteration_step) # run power flow analysis again (after updating pypsa object) and check # if all over-voltage problems were solved @@ -420,7 +394,9 @@ def _add_transformer_changes_to_equipment_changes(mode: str | None): ) ) # write added transformers to results.equipment_changes - _add_transformer_changes_to_equipment_changes("added") + _add_transformer_changes_to_equipment_changes( + edisgo, transformer_changes, iteration_step, "added" + ) # run power flow analysis again (after updating pypsa object) and # check if all over-voltage problems were solved @@ -482,7 +458,9 @@ def _add_transformer_changes_to_equipment_changes(mode: str | None): crit_nodes[crit_nodes.lv_grid_id == grid_id], ) # write changed lines to results.equipment_changes - _add_lines_changes_to_equipment_changes() + _add_lines_changes_to_equipment_changes( + edisgo, lines_changes, iteration_step + ) # run power flow analysis again (after updating pypsa object) # and check if all over-voltage problems were solved @@ -556,7 +534,6 @@ def _add_transformer_changes_to_equipment_changes(mode: str | None): or not overloaded_lv_stations.empty or not crit_lines.empty ) and while_counter < max_while_iterations: - if not overloaded_mv_station.empty: # reinforce substations transformer_changes = ( @@ -565,8 +542,12 @@ def _add_transformer_changes_to_equipment_changes(mode: str | None): ) ) # write added and removed transformers to results.equipment_changes - _add_transformer_changes_to_equipment_changes("added") - _add_transformer_changes_to_equipment_changes("removed") + _add_transformer_changes_to_equipment_changes( + edisgo, transformer_changes, iteration_step, "added" + ) + _add_transformer_changes_to_equipment_changes( + edisgo, transformer_changes, iteration_step, "removed" + ) if not overloaded_lv_stations.empty: # reinforce substations @@ -576,8 +557,12 @@ def _add_transformer_changes_to_equipment_changes(mode: str | None): ) ) # write added and removed transformers to results.equipment_changes - _add_transformer_changes_to_equipment_changes("added") - _add_transformer_changes_to_equipment_changes("removed") + _add_transformer_changes_to_equipment_changes( + edisgo, transformer_changes, iteration_step, "added" + ) + _add_transformer_changes_to_equipment_changes( + edisgo, transformer_changes, iteration_step, "removed" + ) if not crit_lines.empty: # reinforce lines @@ -585,7 +570,9 @@ def _add_transformer_changes_to_equipment_changes(mode: str | None): edisgo, crit_lines ) # write changed lines to results.equipment_changes - _add_lines_changes_to_equipment_changes() + _add_lines_changes_to_equipment_changes( + edisgo, lines_changes, iteration_step + ) # run power flow analysis again (after updating pypsa object) and check # if all over-loading problems were solved @@ -820,7 +807,11 @@ def reinforce(): def enhanced_reinforce_grid( - edisgo_object: EDisGo, activate_cost_results_disturbing_mode: bool = False, **kwargs + edisgo_object: EDisGo, + activate_cost_results_disturbing_mode: bool = False, + separate_lv_grids: bool = True, + separation_threshold: int | float = 2, + **kwargs, ) -> EDisGo: """ Reinforcement strategy to reinforce grids voltage level by voltage level in case @@ -828,9 +819,18 @@ def enhanced_reinforce_grid( :func:`edisgo.flex_opt.reinforce_grid.catch_convergence_reinforce_grid` is not sufficient. - After first grid reinforcement for all voltage levels at once fails, reinforcement - is first conducted for the MV level only, afterwards for the MV level including - MV/LV stations and at last each LV grid separately. + In a first step, if `separate_lv_grids` is set to True, LV grids with a large load, + specified through parameter `separation_threshold`, are split, so that part of the + load is served by a separate MV/LV station. See + :func:`~.flex_opt.reinforce_grid.run_separate_lv_grids` for more information. + Afterwards it is tried to run the grid reinforcement for all voltage levels at once. + If this fails, reinforcement is first conducted for the MV level only, afterwards + for the MV level including MV/LV stations and at last for each LV grid separately. + For each LV grid is it checked, if all time steps converge in the power flow + analysis. If this is not the case, the grid is split. Afterwards it is tried to + be reinforced. If this fails and `activate_cost_results_disturbing_mode` + parameter is set to True, further measures are taken. See parameter documentation + for more information. Parameters ---------- @@ -845,6 +845,12 @@ def enhanced_reinforce_grid( standard line type. Should this not be sufficient to solve non-convergence issues, all components in the LV grid are aggregated to the MV/LV station. Default: False. + separate_lv_grids : bool + If True, all highly overloaded LV grids are separated in a first step. + separation_threshold : int or float + Overloading threshold for LV grid separation. If the overloading is higher than + the threshold times the total nominal apparent power of the MV/LV transformer(s) + the grid is separated. kwargs : dict Keyword arguments can be all parameters of function :func:`edisgo.flex_opt.reinforce_grid.reinforce_grid`, except @@ -861,33 +867,62 @@ def enhanced_reinforce_grid( edisgo_obj = copy.deepcopy(edisgo_object) else: edisgo_obj = edisgo_object + kwargs["copy_grid"] = False kwargs.pop("skip_mv_reinforcement", False) num_lv_grids_standard_lines = 0 num_lv_grids_aggregated = 0 + if separate_lv_grids: + logger.info( + "Separating lv grids. Set the parameter 'separate_lv_grids' to False if " + "this is not desired." + ) + run_separate_lv_grids(edisgo_obj, threshold=separation_threshold) + try: logger.info("Try initial enhanced reinforcement.") edisgo_obj.reinforce(mode=None, catch_convergence_problems=True, **kwargs) logger.info("Initial enhanced reinforcement succeeded.") - except: # noqa: E722 + except (ValueError, RuntimeError): logger.info("Initial enhanced reinforcement failed.") logger.info("Try mode 'mv' reinforcement.") + try: edisgo_obj.reinforce(mode="mv", catch_convergence_problems=True, **kwargs) logger.info("Mode 'mv' reinforcement succeeded.") - except: # noqa: E722 + except (ValueError, RuntimeError): logger.info("Mode 'mv' reinforcement failed.") logger.info("Try mode 'mvlv' reinforcement.") + try: edisgo_obj.reinforce(mode="mvlv", catch_convergence_problems=True, **kwargs) logger.info("Mode 'mvlv' reinforcement succeeded.") - except: # noqa: E722 + except (ValueError, RuntimeError): logger.info("Mode 'mvlv' reinforcement failed.") for lv_grid in list(edisgo_obj.topology.mv_grid.lv_grids): + logger.info(f"Check convergence for {lv_grid=}.") + _, ts_not_converged = edisgo_obj.analyze( + mode="lv", raise_not_converged=False, lv_grid_id=lv_grid.id + ) + if len(ts_not_converged) > 0: + logger.info( + f"Not all time steps converged in power flow analysis for " + f"{lv_grid=}. It is therefore tried to be split.") + transformers_changes, lines_changes = separate_lv_grid( + edisgo_obj, lv_grid + ) + if len(lines_changes) > 0: + _add_lines_changes_to_equipment_changes( + edisgo_obj, lines_changes, 1 + ) + if len(transformers_changes) > 0: + _add_transformer_changes_to_equipment_changes( + edisgo_obj, transformers_changes, 1, "added" + ) try: logger.info(f"Try mode 'lv' reinforcement for {lv_grid=}.") edisgo_obj.reinforce( @@ -897,7 +932,7 @@ def enhanced_reinforce_grid( **kwargs, ) logger.info(f"Mode 'lv' reinforcement for {lv_grid} successful.") - except: # noqa: E722 + except (ValueError, RuntimeError): logger.info(f"Mode 'lv' reinforcement for {lv_grid} failed.") if activate_cost_results_disturbing_mode: try: @@ -920,7 +955,7 @@ def enhanced_reinforce_grid( logger.info( f"Changed lines mode 'lv' for {lv_grid} successful." ) - except: # noqa: E722 + except (ValueError, RuntimeError): logger.info(f"Changed lines mode 'lv' for {lv_grid} failed.") logger.warning( f"Aggregate all nodes to station bus in {lv_grid=}." @@ -933,13 +968,16 @@ def enhanced_reinforce_grid( logger.info( f"Aggregate to station for {lv_grid} successful." ) - except: # noqa: E722 - logger.info(f"Aggregate to station for {lv_grid} failed.") + except Exception as e: + logger.info( + f"Aggregate to station for {lv_grid} failed with " + f"exception:\n{e}" + ) try: edisgo_obj.reinforce(mode=None, catch_convergence_problems=True, **kwargs) logger.info("Enhanced reinforcement succeeded.") - except Exception as e: # noqa: E722 + except Exception as e: logger.info("Enhanced reinforcement failed.") raise e @@ -967,3 +1005,155 @@ def enhanced_reinforce_grid( edisgo_obj.results.measures = msg return edisgo_obj + + +def run_separate_lv_grids(edisgo_obj: EDisGo, threshold: int | float = 2) -> None: + """ + Separate all highly overloaded LV grids within the MV grid. + + The loading is approximated by aggregation of all load and generator time series + and comparison with the total nominal apparent power of the MV/LV transformer(s). + This approach is chosen because this method aims at resolving highly overloaded + grid situations in which cases the power flow often does not converge. This method + ignores grid losses and voltage deviations. Original and new LV grids can be + separated multiple times if the overloading is very high. + + Parameters + ---------- + edisgo_obj : :class:`~.EDisGo` + threshold : int or float + Overloading threshold. If the overloading is higher than the threshold times + the total nominal apparent power of the MV/LV transformer(s), the grid is + separated. + + Returns + ------- + :class:`~.EDisGo` + The reinforced eDisGo object. + + """ + lv_grids = list(edisgo_obj.topology.mv_grid.lv_grids) + n_grids_init = len(lv_grids) + + first_run = True + + active_str = "{}_active_power" + reactive_str = "{}_reactive_power" + tech_str = "{}_df" + techs = ["generators", "loads", "storage_units"] + + n = 0 + max_iterations = 100 + + while ( + n_grids_init != len(list(edisgo_obj.topology.mv_grid.lv_grids)) or first_run + ) and n < max_iterations: + n += 1 + first_run = False + + lv_grids = list(edisgo_obj.topology.mv_grid.lv_grids) + n_grids_init = len(lv_grids) + + for lv_grid in lv_grids: + active_power_dict = {} + reactive_power_dict = {} + + for tech in techs: + units = getattr(lv_grid, tech_str.format(tech)).index + active_power_dict[tech] = ( + getattr( + edisgo_obj.timeseries, + active_str.format(tech), + ) + .loc[:, units] + .astype(float) + .sum(axis=1) + ) + + reactive_power_dict[tech] = ( + getattr( + edisgo_obj.timeseries, + reactive_str.format(tech), + ) + .loc[:, units] + .astype(float) + .sum(axis=1) + ) + + active_power = ( + active_power_dict["loads"] + - active_power_dict["generators"] + - active_power_dict["storage_units"] + ) + + reactive_power = ( + reactive_power_dict["loads"] + - reactive_power_dict["generators"] + - reactive_power_dict["storage_units"] + ) + + worst_case = np.hypot(active_power, reactive_power).max() + + transformers_s_nom = lv_grid.transformers_df.s_nom.sum() + + if worst_case > threshold * transformers_s_nom: + logger.info(f"Trying to separate {lv_grid}...") + transformers_changes, lines_changes = separate_lv_grid( + edisgo_obj, lv_grid + ) + if len(lines_changes) > 0: + _add_lines_changes_to_equipment_changes( + edisgo_obj, lines_changes, 1 + ) + + if len(transformers_changes) > 0: + _add_transformer_changes_to_equipment_changes( + edisgo_obj, transformers_changes, 1, "added" + ) + + else: + logger.debug( + f"The overloading in {lv_grid} does not surpass the set threshold " + f"of {threshold}. The grid is therefore not separated." + ) + + +def _add_lines_changes_to_equipment_changes( + edisgo: EDisGo, lines_changes: dict, iteration_step: int +) -> None: + edisgo.results.equipment_changes = pd.concat( + [ + edisgo.results.equipment_changes, + pd.DataFrame( + { + "iteration_step": [iteration_step] * len(lines_changes), + "change": ["changed"] * len(lines_changes), + "equipment": edisgo.topology.lines_df.loc[ + lines_changes.keys(), "type_info" + ].values, + "quantity": [_ for _ in lines_changes.values()], + }, + index=list(lines_changes.keys()), + ), + ], + ) + + +def _add_transformer_changes_to_equipment_changes( + edisgo: EDisGo, transformer_changes: dict, iteration_step: int, mode: str | None +) -> None: + df_list = [edisgo.results.equipment_changes] + df_list.extend( + pd.DataFrame( + { + "iteration_step": [iteration_step] * len(transformer_list), + "change": [mode] * len(transformer_list), + "equipment": transformer_list, + "quantity": [1] * len(transformer_list), + }, + index=[station] * len(transformer_list), + ) + for station, transformer_list in transformer_changes[mode].items() + ) + + edisgo.results.equipment_changes = pd.concat(df_list) diff --git a/edisgo/flex_opt/reinforce_measures.py b/edisgo/flex_opt/reinforce_measures.py index 56cb9d8a..b93e0f27 100644 --- a/edisgo/flex_opt/reinforce_measures.py +++ b/edisgo/flex_opt/reinforce_measures.py @@ -1,6 +1,10 @@ +from __future__ import annotations + import logging import math +from typing import TYPE_CHECKING, Any + import networkx as nx import numpy as np import pandas as pd @@ -10,6 +14,10 @@ ) from edisgo.network.grids import LVGrid, MVGrid +from edisgo.tools.tools import get_downstream_buses + +if TYPE_CHECKING: + from edisgo import EDisGo logger = logging.getLogger(__name__) @@ -407,7 +415,6 @@ def reinforce_lines_voltage_issues(edisgo_obj, grid, crit_nodes): lines_changes = {} for repr_node in nodes_feeder.keys(): - # find node farthest away get_weight = lambda u, v, data: data["length"] # noqa: E731 path_length = 0 @@ -504,7 +511,7 @@ def reinforce_lines_voltage_issues(edisgo_obj, grid, crit_nodes): ] = path_length_dict[node_2_3] edisgo_obj.topology.change_line_type([crit_line_name], standard_line) lines_changes[crit_line_name] = 1 - # ToDo: Include switch disconnector + # TODO: Include switch disconnector if not lines_changes: logger.debug( @@ -725,3 +732,435 @@ def _replace_by_parallel_standard_lines(lines): _replace_by_parallel_standard_lines(relevant_lines.index) return lines_changes + + +def separate_lv_grid( + edisgo_obj: EDisGo, grid: LVGrid +) -> tuple[dict[Any, Any], dict[str, int]]: + """ + Separate LV grid by adding a new substation and connect half of each feeder. + + If a feeder cannot be split because it has too few nodes or too few nodes outside a + building, each second inept feeder is connected to the new LV grid. The new LV grid + is equipped with standard transformers until the nominal apparent power is at least + the same as in the original LV grid. The new substation is at the same location as + the originating substation. The workflow is as follows: + + * The point at half the length of the feeders is determined. + * The first node following this point is chosen as the point where the new + connection will be made. + * New MV/LV station is connected to the existing MV/LV station. + * The determined nodes are disconnected from the previous nodes and connected to the + new MV/LV station. + + Notes: + + * The name of the new LV grid will be a combination of the originating existing grid + ID. E.g. 40000 + X = 40000X + * The name of the lines in the new LV grid are the same as in the grid where the + nodes were removed + * Except line names, all the data frames are named based on the new grid name + + Parameters + ---------- + edisgo_obj : :class:`~.EDisGo` + grid : :class:`~.network.grids.LVGrid` + + Returns + ------- + dict + Dictionary with name of lines as keys and the corresponding number of + lines added as values. + dict + Dictionary with added transformers in the form:: + + {'added': {'Grid_1': ['transformer_reinforced_1', + ..., + 'transformer_reinforced_x'], + 'Grid_10': ['transformer_reinforced_10'] + } + } + + """ + + def get_weight(u, v, data: dict) -> float: + return data["length"] + + def create_bus_name(bus: str, lv_grid_id_new: int, voltage_level: str) -> str: + """ + Create an LV and MV bus-bar name with the same grid_id but added '1001' which + implies the separation. + + Parameters + ---------- + bus : str + Bus name. E.g. 'BusBar_mvgd_460_lvgd_131573_LV' + voltage_level : str + 'mv' or 'lv' + + Returns + ---------- + str + New bus-bar name. + + """ + if bus in edisgo_obj.topology.buses_df.index: + bus = bus.split("_") + + bus[-2] = lv_grid_id_new + + if voltage_level == "lv": + bus = "_".join([str(_) for _ in bus]) + elif voltage_level == "mv": + bus[-1] = "MV" + bus = "_".join([str(_) for _ in bus]) + else: + logger.error( + f"Voltage level can only be 'mv' or 'lv'. Voltage level used: " + f"{voltage_level}." + ) + else: + raise IndexError(f"Station bus {bus} is not within the buses DataFrame.") + + return bus + + def add_standard_transformer( + edisgo_obj: EDisGo, grid: LVGrid, bus_lv: str, bus_mv: str, lv_grid_id_new: int + ) -> dict: + """ + Adds standard transformer to topology. + + Parameters + ---------- + edisgo_obj : class:`~.EDisGo` + grid : `~.network.grids.LVGrid` + bus_lv : str + Identifier of LV bus. + bus_mv : str + Identifier of MV bus. + + Returns + ---------- + dict + + """ + if bus_lv not in edisgo_obj.topology.buses_df.index: + raise ValueError( + f"Specified bus {bus_lv} is not valid as it is not defined in " + "buses_df." + ) + if bus_mv not in edisgo_obj.topology.buses_df.index: + raise ValueError( + f"Specified bus {bus_mv} is not valid as it is not defined in " + "buses_df." + ) + + try: + standard_transformer = edisgo_obj.topology.equipment_data[ + "lv_transformers" + ].loc[ + edisgo_obj.config["grid_expansion_standard_equipment"][ + "mv_lv_transformer" + ] + ] + except KeyError: + raise KeyError("Standard MV/LV transformer is not in the equipment list.") + + transformer_changes = {"added": {}} + + new_transformer_df = grid.transformers_df.iloc[[0]] + new_transformer_name = new_transformer_df.index[0].split("_") + grid_id_ind = new_transformer_name.index(str(grid.id)) + new_transformer_name[grid_id_ind] = lv_grid_id_new + + new_transformer_df.s_nom = standard_transformer.S_nom + new_transformer_df.type_info = None + new_transformer_df.r_pu = standard_transformer.r_pu + new_transformer_df.x_pu = standard_transformer.x_pu + new_transformer_df.index = ["_".join([str(_) for _ in new_transformer_name])] + new_transformer_df.bus0 = bus_mv + new_transformer_df.bus1 = bus_lv + + old_s_nom = grid.transformers_df.s_nom.sum() + + max_iterations = 10 + n = 0 + + while old_s_nom > new_transformer_df.s_nom.sum() and n < max_iterations: + n += 1 + + another_new_transformer = new_transformer_df.iloc[-1:, :] + + old_name = another_new_transformer.index[0] + + name = old_name.split("_") + + try: + name[-1] = str(int(name[-1]) + 1) + except ValueError: + name.append("1") + + name = "_".join(name) + + another_new_transformer.rename(index={old_name: name}, inplace=True) + + new_transformer_df = pd.concat( + [new_transformer_df, another_new_transformer] + ) + + edisgo_obj.topology.transformers_df = pd.concat( + [edisgo_obj.topology.transformers_df, new_transformer_df] + ) + transformer_changes["added"][ + f"LVGrid_{lv_grid_id_new}" + ] = new_transformer_df.index.tolist() + + return transformer_changes + + G = grid.graph + + # main station + station_node = grid.transformers_df.bus1.iat[0] + + relevant_lines = grid.lines_df.loc[ + (grid.lines_df.bus0 == station_node) | (grid.lines_df.bus1 == station_node) + ] + + first_nodes = set(relevant_lines.bus0).union(set(relevant_lines.bus1)) - { + station_node, + } + + if len(relevant_lines) <= 1: + logger.warning( + f"{grid} has only {len(relevant_lines)} feeder and is therefore not " + f"separated." + ) + + return {}, {} + + logger.debug(f"{grid} has {len(relevant_lines)} feeder.") + + paths = {} + first_nodes_feeders = {} + + # determine ordered shortest path between each node and the station node and each + # node per feeder + for node in G.nodes: + if node == station_node: + continue + + path = nx.shortest_path(G, station_node, node) + + for first_node in first_nodes: + if first_node in path: + paths[node] = path + + first_nodes_feeders.setdefault(first_node, []).append( + node # first nodes and paths + ) + + # note: The number of critical lines in the Lv grid can be more than 2. However, + # if the node_1_2 of the first feeder in the for loop is not the first node of the + # feeder, it will add data frames even though the following feeders only 1 node + # (node_1_2=first node of feeder). In this type of case,the number of critical lines + # should be evaluated for the feeders whose node_1_2 s are not the first node of the + # feeder. The first check should be done on the feeders that have fewer nodes. + + first_nodes_feeders = dict( + sorted( + first_nodes_feeders.items(), key=lambda item: len(item[1]), reverse=False + ) + ) + + # make sure nodes are sorted correctly and node_1_2 is part of the main feeder + for first_node, nodes_feeder in first_nodes_feeders.items(): + paths_first_node = { + node: path for node, path in paths.items() if path[1] == first_node + } + + # identify main feeder by maximum number of nodes in path + first_nodes_feeders[first_node] = paths_first_node[ + max(paths_first_node, key=lambda x: len(paths_first_node[x])) + ] + + lines_changes = {} + transformers_changes = {} + nodes_tb_relocated = {} # nodes to be moved into the new grid + + count_inept = 0 + + for first_node, nodes_feeder in first_nodes_feeders.items(): + # first line of the feeder + first_line = relevant_lines[ + (relevant_lines.bus1 == first_node) | (relevant_lines.bus0 == first_node) + ].index[0] + + # the last node of the feeder + last_node = nodes_feeder[-1] + + # the length of each line (the shortest path) + path_length_dict_tmp = dijkstra_shortest_path_length( + G, station_node, get_weight, target=last_node + ) + + # path does not include the nodes branching from the node on the main path + path = paths[last_node] + + # TODO: replace this to be weighted by the connected load per bus incl. + # branched of feeders + node_1_2 = next( + j + for j in path + if path_length_dict_tmp[j] >= path_length_dict_tmp[last_node] * 1 / 2 + ) + + # if LVGrid: check if node_1_2 is outside a house + # and if not find next BranchTee outside the house + while ( + ~np.isnan(grid.buses_df.loc[node_1_2].in_building) + and grid.buses_df.loc[node_1_2].in_building + ): + node_1_2 = path[path.index(node_1_2) - 1] + # break if node is station + if node_1_2 is path[0]: + logger.warning( + f"{grid} ==> {first_line} and following lines could not be " + f"reinforced due to insufficient number of node in the feeder. " + f"A method to handle such cases is not yet implemented." + ) + + node_1_2 = path[path.index(node_1_2) + 1] + + break + + # NOTE: If node_1_2 is a representative (meaning it is already directly + # connected to the station) feeder cannot be split. Instead, every second + # inept feeder is assigned to the new grid + if node_1_2 not in first_nodes_feeders or count_inept % 2 == 1: + nodes_tb_relocated[node_1_2] = get_downstream_buses(edisgo_obj, node_1_2) + + if node_1_2 in first_nodes_feeders: + count_inept += 1 + else: + count_inept += 1 + + if nodes_tb_relocated: + # generate new lv grid id + n = 0 + lv_grid_id_new = int(f"{grid.id}{n}") + + max_iterations = 10**4 + + g_ids = [g.id for g in edisgo_obj.topology.mv_grid.lv_grids] + + while lv_grid_id_new in g_ids: + n += 1 + lv_grid_id_new = int(f"{grid.id}{n}") + + if n >= max_iterations: + raise ValueError( + f"No suitable name for the new LV grid originating from {grid} was " + f"found in {max_iterations=}." + ) + + # Create the bus-bar name of primary and secondary side of new MV/LV station + lv_bus_new = create_bus_name(station_node, lv_grid_id_new, "lv") + mv_bus = grid.transformers_df.bus0.iat[0] + + # Add MV and LV bus + v_nom_lv = edisgo_obj.topology.buses_df.at[ + grid.transformers_df.bus1[0], + "v_nom", + ] + + x_bus = grid.buses_df.at[station_node, "x"] + y_bus = grid.buses_df.at[station_node, "y"] + + building_bus = grid.buses_df.at[station_node, "in_building"] + + # add lv busbar + edisgo_obj.topology.add_bus( + lv_bus_new, + v_nom_lv, + x=x_bus, + y=y_bus, + lv_grid_id=lv_grid_id_new, + in_building=building_bus, + ) + + # ADD TRANSFORMER + transformer_changes = add_standard_transformer( + edisgo_obj, grid, lv_bus_new, mv_bus, lv_grid_id_new + ) + transformers_changes.update(transformer_changes) + + logger.info(f"New LV grid {lv_grid_id_new} added to topology.") + + lv_standard_line = edisgo_obj.config["grid_expansion_standard_equipment"][ + "lv_line" + ] + + # changes on relocated lines to the new LV grid + # grid_ids + for node_1_2, nodes in nodes_tb_relocated.items(): + # the last node of the feeder + last_node = nodes[-1] + + # path does not include the nodes branching from the node on the main path + path = paths[last_node] + + nodes.append(node_1_2) + + edisgo_obj.topology.buses_df.loc[nodes, "lv_grid_id"] = lv_grid_id_new + + dist = dijkstra_shortest_path_length( + G, station_node, get_weight, target=node_1_2 + )[node_1_2] + + line_added_lv = edisgo_obj.add_component( + comp_type="line", + bus0=lv_bus_new, + bus1=node_1_2, + length=dist, + type_info=lv_standard_line, + ) + + lines_changes[line_added_lv] = 1 + + # predecessor node of node_1_2 + pred_node = path[path.index(node_1_2) - 1] + + # the line + line_removed = G.get_edge_data(node_1_2, pred_node)["branch_name"] + + edisgo_obj.remove_component( + comp_type="line", + comp_name=line_removed, + ) + + logger.info( + f"{len(nodes_tb_relocated.keys())} feeders are removed from the grid " + f"{grid} and located in new grid {lv_grid_id_new} by method: " + f"add_station_at_half_length " + ) + + # check if new grids have isolated nodes + grids = [ + g + for g in edisgo_obj.topology.mv_grid.lv_grids + if g.id in [grid.id, lv_grid_id_new] + ] + + for g in grids: + n = nx.number_of_isolates(g.graph) + + if n > 0 and len(g.buses_df) > 1: + raise ValueError( + f"There are isolated nodes in {g}. The following nodes are " + f"isolated: {list(nx.isolates(g.graph))}" + ) + + else: + logger.warning(f"{grid} was not split because it has too few suitable feeders.") + + return transformers_changes, lines_changes diff --git a/edisgo/io/electromobility_import.py b/edisgo/io/electromobility_import.py index cfc87462..223cea02 100644 --- a/edisgo/io/electromobility_import.py +++ b/edisgo/io/electromobility_import.py @@ -1077,14 +1077,12 @@ def distribute_public_charging_demand(edisgo_obj, **kwargs): edisgo_obj.electromobility.charging_processes_df.at[ idx, "charging_point_id" ] = charging_point_id - try: - available_charging_points_df.loc[ - charging_point_id - ] = edisgo_obj.electromobility.charging_processes_df.loc[ - idx, available_charging_points_df.columns - ].tolist() - except Exception: - print("break") + + available_charging_points_df.loc[ + charging_point_id + ] = edisgo_obj.electromobility.charging_processes_df.loc[ + idx, available_charging_points_df.columns + ].tolist() designated_charging_point_capacity_df.at[ charging_park_id, "designated_charging_point_capacity" diff --git a/edisgo/io/pypsa_io.py b/edisgo/io/pypsa_io.py index d795d327..3825a4d6 100755 --- a/edisgo/io/pypsa_io.py +++ b/edisgo/io/pypsa_io.py @@ -195,9 +195,9 @@ def _set_slack(grid): pypsa_network.mode = "lv" lv_grid_id = kwargs.get("lv_grid_id", None) - if not lv_grid_id: + if lv_grid_id is None: raise ValueError( - "For exporting LV grids, ID or name of LV grid has to be provided" + "For exporting LV grids, ID or name of LV grid has to be provided " "using parameter `lv_grid_id`." ) grid_object = edisgo_object.topology.get_lv_grid(lv_grid_id) diff --git a/edisgo/network/components.py b/edisgo/network/components.py index 5e4df1fb..8c219340 100644 --- a/edisgo/network/components.py +++ b/edisgo/network/components.py @@ -894,8 +894,6 @@ def nearest_substation(self): """ substations = self._topology.buses_df.loc[self._topology.transformers_df.bus1] - if self.geometry.y > 90: - print("break") nearest_substation, distance = find_nearest_bus(self.geometry, substations) lv_grid_id = int(self._topology.buses_df.at[nearest_substation, "lv_grid_id"]) diff --git a/edisgo/tools/tools.py b/edisgo/tools/tools.py index c3856806..fccbf019 100644 --- a/edisgo/tools/tools.py +++ b/edisgo/tools/tools.py @@ -400,7 +400,7 @@ def get_downstream_buses(edisgo_obj, comp_name, comp_type="bus"): ------- list(str) List of buses (as in index of :attr:`~.network.topology.Topology.buses_df`) - downstream of the given component. + downstream of the given component incl. the initial bus. """ graph = edisgo_obj.topology.to_graph() @@ -420,17 +420,22 @@ def get_downstream_buses(edisgo_obj, comp_name, comp_type="bus"): bus = bus0 if len(path_to_station_bus0) > len(path_to_station_bus1) else bus1 bus_upstream = bus0 if bus == bus1 else bus1 else: - return None + raise ValueError( + f"Component type needs to be either 'bus' or 'line'. Given {comp_type=} is " + f"not valid." + ) # remove edge between bus and next bus upstream graph.remove_edge(bus, bus_upstream) # get subgraph containing relevant bus - subgraphs = list(graph.subgraph(c) for c in nx.connected_components(graph)) + subgraphs = [graph.subgraph(c) for c in nx.connected_components(graph)] + for subgraph in subgraphs: if bus in subgraph.nodes(): return list(subgraph.nodes()) - return None + + return [bus] def assign_voltage_level_to_component(df, buses_df): diff --git a/tests/flex_opt/test_reinforce_grid.py b/tests/flex_opt/test_reinforce_grid.py index 26e96ad7..dd4ca4cb 100644 --- a/tests/flex_opt/test_reinforce_grid.py +++ b/tests/flex_opt/test_reinforce_grid.py @@ -7,7 +7,8 @@ from pandas.testing import assert_frame_equal from edisgo import EDisGo -from edisgo.flex_opt.reinforce_grid import reinforce_grid +from edisgo.flex_opt.costs import grid_expansion_costs +from edisgo.flex_opt.reinforce_grid import reinforce_grid, run_separate_lv_grids class TestReinforceGrid: @@ -63,3 +64,45 @@ def test_reinforce_grid(self): assert_frame_equal( res_reduced.equipment_changes, results_dict[None].equipment_changes ) + + def test_run_separate_lv_grids(self): + edisgo = copy.deepcopy(self.edisgo) + + edisgo.timeseries.scale_timeseries(p_scaling_factor=5, q_scaling_factor=5) + + lv_grids = [copy.deepcopy(g) for g in edisgo.topology.mv_grid.lv_grids] + + run_separate_lv_grids(edisgo) + + edisgo.results.grid_expansion_costs = grid_expansion_costs(edisgo) + lv_grids_new = list(edisgo.topology.mv_grid.lv_grids) + + # check that no new lv grid only consist of the station + for g in lv_grids_new: + if g.id != 0: + assert len(g.buses_df) > 1 + + assert len(lv_grids_new) == 26 + assert np.isclose(edisgo.results.grid_expansion_costs.total_costs.sum(), 280.06) + + # check if all generators are still present + assert np.isclose( + sum(g.generators_df.p_nom.sum() for g in lv_grids), + sum(g.generators_df.p_nom.sum() for g in lv_grids_new), + ) + + # check if all loads are still present + assert np.isclose( + sum(g.loads_df.p_set.sum() for g in lv_grids), + sum(g.loads_df.p_set.sum() for g in lv_grids_new), + ) + + # check if all storages are still present + assert np.isclose( + sum(g.storage_units_df.p_nom.sum() for g in lv_grids), + sum(g.storage_units_df.p_nom.sum() for g in lv_grids_new), + ) + + # test if power flow works + edisgo.set_time_series_worst_case_analysis() + edisgo.analyze() diff --git a/tests/flex_opt/test_reinforce_measures.py b/tests/flex_opt/test_reinforce_measures.py index 40acf4ea..5ea72006 100644 --- a/tests/flex_opt/test_reinforce_measures.py +++ b/tests/flex_opt/test_reinforce_measures.py @@ -5,7 +5,7 @@ import pytest from edisgo import EDisGo -from edisgo.flex_opt import reinforce_measures +from edisgo.flex_opt import check_tech_constraints, reinforce_measures class TestReinforceMeasures: @@ -453,3 +453,67 @@ def test_reinforce_lines_overloading(self): assert np.isclose(line.x, 0.256 * 2 * np.pi * 50 / 1e3 * line.length) assert np.isclose(line.s_nom, 0.275 * 0.4 * np.sqrt(3)) assert line.num_parallel == 1 + + def test_separate_lv_grid(self): + self.edisgo = copy.deepcopy(self.edisgo_root) + + crit_lines_lv = check_tech_constraints.lv_line_max_relative_overload( + self.edisgo + ) + + all_lv_grid_ids = [ + lv_grid.id for lv_grid in self.edisgo.topology.mv_grid.lv_grids + ] + + lv_grid_ids = ( + self.edisgo.topology.buses_df.loc[ + self.edisgo.topology.lines_df.loc[crit_lines_lv.index].bus0 + ] + .lv_grid_id.unique() + .astype(int) + ) + + lv_grids = [ + lv_grid + for lv_grid in self.edisgo.topology.mv_grid.lv_grids + if lv_grid.id in lv_grid_ids + ] + + for lv_grid in lv_grids: + orig_g = copy.deepcopy(lv_grid) + grid_id = orig_g.id + + reinforce_measures.separate_lv_grid(self.edisgo, lv_grid) + + new_g_0 = [ + g for g in self.edisgo.topology.mv_grid.lv_grids if g.id == grid_id + ][0] + + try: + new_g_1 = [ + g + for g in self.edisgo.topology.mv_grid.lv_grids + if g.id not in all_lv_grid_ids + ][0] + + all_lv_grid_ids.append(new_g_1.id) + except IndexError: + continue + + assert np.isclose( + orig_g.charging_points_df.p_set.sum(), + new_g_0.charging_points_df.p_set.sum() + + new_g_1.charging_points_df.p_set.sum(), + ) + + assert np.isclose( + orig_g.generators_df.p_nom.sum(), + new_g_0.generators_df.p_nom.sum() + new_g_1.generators_df.p_nom.sum(), + ) + + assert np.isclose( + orig_g.loads_df.p_set.sum(), + new_g_0.loads_df.p_set.sum() + new_g_1.loads_df.p_set.sum(), + ) + + assert len(orig_g.lines_df) == len(new_g_0.lines_df) + len(new_g_1.lines_df) diff --git a/tests/test_edisgo.py b/tests/test_edisgo.py index 11b2c65d..0069eba0 100755 --- a/tests/test_edisgo.py +++ b/tests/test_edisgo.py @@ -527,13 +527,17 @@ def test_reinforce_catch_convergence(self): @pytest.mark.slow def test_enhanced_reinforce_grid(self): self.setup_edisgo_object() + self.setup_worst_case_time_series() self.edisgo.timeseries.scale_timeseries( p_scaling_factor=100, q_scaling_factor=100 ) + edisgo_obj = copy.deepcopy(self.edisgo) edisgo_obj = enhanced_reinforce_grid( - edisgo_obj, activate_cost_results_disturbing_mode=True + edisgo_obj, + activate_cost_results_disturbing_mode=True, + separate_lv_grids=False, ) results = edisgo_obj.results