diff --git a/doc/conf.py b/doc/conf.py index 32f385cbc..851a3fb6d 100644 --- a/doc/conf.py +++ b/doc/conf.py @@ -116,7 +116,7 @@ def setup(sphinx): "networkx.%s", ), "sqlalchemy": ( - "http://docs.sqlalchemy.org/en/latest/core/connections.html#%s", + "https://docs.sqlalchemy.org/en/latest/core/connections.html#%s", "sqlalchemy.%s", ), "numpy": ( @@ -134,6 +134,11 @@ def setup(sphinx): "plotly.%s", ), } +# ignore the following external links when checking the links +# stackoverflow is listed here because for some reason the link check fails for these +# in the github action, even though the link is correct +linkcheck_ignore = [r"https://stackoverflow.com*"] + # Add any paths that contain templates here, relative to this directory. templates_path = ["_templates"] diff --git a/doc/quickstart.rst b/doc/quickstart.rst index 21d38303b..4ce437c05 100644 --- a/doc/quickstart.rst +++ b/doc/quickstart.rst @@ -91,7 +91,7 @@ The steps required to set up HSL are also described in the Here is a short version for reference: First, you need to obtain an academic license for HSL Solvers. -Under https://www.hsl.rl.ac.uk/ipopt/ download the sources for Coin-HSL Full (Stable). +Under https://licences.stfc.ac.uk/product/coin-hsl download the sources for Coin-HSL Full (Stable). You will need to provide an institutional e-mail to gain access. Unpack the tar.gz: @@ -163,7 +163,7 @@ Beyond a running and up-to-date installation of eDisGo you need **grid topology data**. Currently synthetic grid data generated with the python project `Ding0 `_ is the only supported data source. You can retrieve data from -`Zenodo `_ +`Zenodo `_ (make sure you choose latest data) or check out the `Ding0 documentation `_ on how to generate grids yourself. diff --git a/doc/whatsnew/v0-3-0.rst b/doc/whatsnew/v0-3-0.rst index 3eeccb3c2..7cb375542 100644 --- a/doc/whatsnew/v0-3-0.rst +++ b/doc/whatsnew/v0-3-0.rst @@ -22,6 +22,7 @@ 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 `_ + (adapted in `#395 `_) * Added optimization method to determine dispatch of flexibilities that lead to minimal network expansion costs `#376 `_ * Added a new reinforcement method that separate lv grids when the overloading is very high `#380 `_ * Move function to assign feeder to Topology class and add methods to the Grid class to get information on the feeders `#360 `_ diff --git a/eDisGo_env.yml b/eDisGo_env.yml index f35301247..45b797e16 100644 --- a/eDisGo_env.yml +++ b/eDisGo_env.yml @@ -5,7 +5,7 @@ channels: dependencies: - python >= 3.8, < 3.10 - pip - - pandas >= 1.4 + - pandas >= 1.4, < 2.2.0 - conda-forge::fiona - conda-forge::geopy - conda-forge::geopandas diff --git a/eDisGo_env_dev.yml b/eDisGo_env_dev.yml index a59866094..ae86632ac 100644 --- a/eDisGo_env_dev.yml +++ b/eDisGo_env_dev.yml @@ -5,7 +5,7 @@ channels: dependencies: - python >= 3.8, < 3.10 - pip - - pandas >= 1.4 + - pandas >= 1.4, < 2.2.0 - conda-forge::fiona - conda-forge::geopy - conda-forge::geopandas diff --git a/edisgo/edisgo.py b/edisgo/edisgo.py index f0edd32b6..dca1660b2 100755 --- a/edisgo/edisgo.py +++ b/edisgo/edisgo.py @@ -998,7 +998,7 @@ def analyze( range_num: int = 10, scale_timeseries: float | None = None, **kwargs, - ) -> tuple[pd.DataFrame, pd.DataFrame]: + ) -> tuple[pd.DatetimeIndex, pd.DatetimeIndex]: """ Conducts a static, non-linear power flow analysis. @@ -1196,6 +1196,7 @@ def _scale_timeseries(pypsa_network_copy, fraction): def reinforce( self, timesteps_pfa: str | pd.DatetimeIndex | pd.Timestamp | None = None, + reduced_analysis: bool = False, copy_grid: bool = False, max_while_iterations: int = 20, split_voltage_band: bool = True, @@ -1237,14 +1238,15 @@ def reinforce( time steps. If your time series already represents the worst-case, keep the default value of None because finding the worst-case snapshots takes some time. - * 'reduced_analysis' - Reinforcement is conducted for all time steps at which at least one - branch shows its highest overloading or one bus shows its highest voltage - violation. * :pandas:`pandas.DatetimeIndex` or \ :pandas:`pandas.Timestamp` Use this option to explicitly choose which time steps to consider. - + reduced_analysis : bool + If True, reinforcement is conducted for all time steps at which at least + one branch shows its highest overloading or one bus shows its highest + voltage violation. Time steps to consider are specified through parameter + `timesteps_pfa`. If False, all time steps in parameter `timesteps_pfa` + are used. Default: False. copy_grid : bool If True, reinforcement is conducted on a copied grid and discarded. Default: False. @@ -1301,26 +1303,43 @@ def reinforce( reinforce MV/LV stations for LV worst-cases. Default: False. num_steps_loading : int - In case `timesteps_pfa` is set to 'reduced_analysis', this parameter can be + In case `reduced_analysis` is set to True, this parameter can be used to specify the number of most critical overloading events to consider. If None, `percentage` is used. Default: None. num_steps_voltage : int - In case `timesteps_pfa` is set to 'reduced_analysis', this parameter can be + In case `reduced_analysis` is set to True, this parameter can be used to specify the number of most critical voltage issues to select. If None, `percentage` is used. Default: None. percentage : float - In case `timesteps_pfa` is set to 'reduced_analysis', this parameter can be + In case `reduced_analysis` is set to True, this parameter can be used to specify the percentage of most critical time steps to select. The default is 1.0, in which case all most critical time steps are selected. Default: 1.0. use_troubleshooting_mode : bool - In case `timesteps_pfa` is set to 'reduced_analysis', this parameter can be - used to specify how to handle non-convergence issues in the power flow - analysis. If set to True, non-convergence issues are tried to be + In case `reduced_analysis` is set to True, this parameter can be used to + specify how to handle non-convergence issues when determining the most + critical time steps. If set to True, non-convergence issues are tried to be circumvented by reducing load and feed-in until the power flow converges. The most critical time steps are then determined based on the power flow results with the reduced load and feed-in. If False, an error will be - raised in case time steps do not converge. Default: True. + raised in case time steps do not converge. + Setting this to True doesn't make sense for the grid reinforcement as the + troubleshooting mode is only used when determining the most critical time + steps not when running a power flow analysis to determine grid reinforcement + needs. To handle non-convergence in the grid reinforcement set parameter + `catch_convergence_problems` to True. + Default: False. + run_initial_analyze : bool + In case `reduced_analysis` is set to True, this parameter can be + used to specify whether to run an initial analyze to determine most + critical time steps or to use existing results. If set to False, + `use_troubleshooting_mode` is ignored. Default: True. + weight_by_costs : bool + In case `reduced_analysis` is set to True, this parameter can be used + to specify whether to weight time steps by estimated grid expansion costs. + See parameter `weight_by_costs` in + :func:`~.tools.temporal_complexity_reduction.get_most_critical_time_steps` + for more information. Default: False. Returns -------- @@ -1407,6 +1426,7 @@ def reinforce( func( edisgo_obj, + reduced_analysis=reduced_analysis, max_while_iterations=max_while_iterations, split_voltage_band=split_voltage_band, without_generator_import=without_generator_import, diff --git a/edisgo/flex_opt/check_tech_constraints.py b/edisgo/flex_opt/check_tech_constraints.py index 3abf0b671..1a90dc36f 100644 --- a/edisgo/flex_opt/check_tech_constraints.py +++ b/edisgo/flex_opt/check_tech_constraints.py @@ -709,7 +709,7 @@ def stations_relative_load(edisgo_obj, grids=None): except Exception: pass - return loading / allowed_loading.loc[:, loading.columns] + return loading / allowed_loading.loc[loading.index, loading.columns] def components_relative_load(edisgo_obj, n_minus_one=False): @@ -1190,6 +1190,10 @@ def voltage_deviation_from_allowed_voltage_limits( v_dev_allowed_upper, v_dev_allowed_lower = allowed_voltage_limits( edisgo_obj, buses=buses, split_voltage_band=split_voltage_band ) + # the following is needed in case the power flow was only conducted for one LV + # grid - voltage at station node cannot be checked, warning is already raised + # in allowed_voltage_limits() + buses = v_dev_allowed_upper.columns # get voltages from power flow analysis v_mag_pu_pfa = edisgo_obj.results.v_res.loc[:, buses] diff --git a/edisgo/flex_opt/reinforce_grid.py b/edisgo/flex_opt/reinforce_grid.py index 89a0a6515..e56eb58b4 100644 --- a/edisgo/flex_opt/reinforce_grid.py +++ b/edisgo/flex_opt/reinforce_grid.py @@ -25,6 +25,7 @@ def reinforce_grid( edisgo: EDisGo, timesteps_pfa: str | pd.DatetimeIndex | pd.Timestamp | None = None, + reduced_analysis: bool = False, max_while_iterations: int = 20, split_voltage_band: bool = True, mode: str | None = None, @@ -47,6 +48,10 @@ def reinforce_grid( timesteps_pfa specifies for which time steps power flow analysis is conducted. See parameter `timesteps_pfa` in function :attr:`~.EDisGo.reinforce` for more information. + reduced_analysis : bool + Specifies, whether to run reinforcement on a subset of time steps that are most + critical. See parameter `reduced_analysis` in function + :attr:`~.EDisGo.reinforce` for more information. max_while_iterations : int Maximum number of times each while loop is conducted. Default: 20. split_voltage_band : bool @@ -84,23 +89,34 @@ def reinforce_grid( reinforce MV/LV stations for LV worst-cases. Default: False. num_steps_loading : int - In case `timesteps_pfa` is set to 'reduced_analysis', this parameter can be used + In case `reduced_analysis` is set to True, this parameter can be used to specify the number of most critical overloading events to consider. If None, `percentage` is used. Default: None. num_steps_voltage : int - In case `timesteps_pfa` is set to 'reduced_analysis', this parameter can be used + In case `reduced_analysis` is set to True, this parameter can be used to specify the number of most critical voltage issues to select. If None, `percentage` is used. Default: None. percentage : float - In case `timesteps_pfa` is set to 'reduced_analysis', this parameter can be used + In case `reduced_analysis` is set to True, this parameter can be used to specify the percentage of most critical time steps to select. The default is 1.0, in which case all most critical time steps are selected. Default: 1.0. use_troubleshooting_mode : bool - In case `timesteps_pfa` is set to 'reduced_analysis', this parameter can be used + In case `reduced_analysis` is set to True, this parameter can be used to specify how to handle non-convergence issues in the power flow analysis. See parameter `use_troubleshooting_mode` in function :attr:`~.EDisGo.reinforce` - for more information. Default: True. + for more information. Default: False. + run_initial_analyze : bool + In case `reduced_analysis` is set to True, this parameter can be + used to specify whether to run an initial analyze to determine most + critical time steps or to use existing results. If set to False, + `use_troubleshooting_mode` is ignored. Default: True. + weight_by_costs : bool + In case `reduced_analysis` is set to True, this parameter can be + used to specify whether to weight time steps by estimated grid expansion costs. + See parameter `weight_by_costs` in + :func:`~.tools.temporal_complexity_reduction.get_most_critical_time_steps` + for more information. Default: False. Returns ------- @@ -139,14 +155,6 @@ def reinforce_grid( snapshots["min_residual_load"], ] ).dropna() - elif isinstance(timesteps_pfa, str) and timesteps_pfa == "reduced_analysis": - timesteps_pfa = get_most_critical_time_steps( - edisgo, - num_steps_loading=kwargs.get("num_steps_loading", None), - num_steps_voltage=kwargs.get("num_steps_voltage", None), - percentage=kwargs.get("percentage", 1.0), - use_troubleshooting_mode=kwargs.get("use_troubleshooting_mode", True), - ) # if timesteps_pfa is not of type datetime or does not contain # datetimes throw an error elif not isinstance(timesteps_pfa, datetime.datetime): @@ -170,6 +178,24 @@ def reinforce_grid( else: analyze_mode = mode + if reduced_analysis: + timesteps_pfa = get_most_critical_time_steps( + edisgo, + mode=analyze_mode, + timesteps=timesteps_pfa, + lv_grid_id=lv_grid_id, + scale_timeseries=scale_timeseries, + num_steps_loading=kwargs.get("num_steps_loading", None), + num_steps_voltage=kwargs.get("num_steps_voltage", None), + percentage=kwargs.get("percentage", 1.0), + use_troubleshooting_mode=kwargs.get("use_troubleshooting_mode", False), + run_initial_analyze=kwargs.get("run_initial_analyze", True), + weight_by_costs=kwargs.get("weight_by_costs", False), + ) + if timesteps_pfa is not None and len(timesteps_pfa) == 0: + logger.debug("Zero time steps for grid reinforcement.") + return edisgo.results + edisgo.analyze( mode=analyze_mode, timesteps=timesteps_pfa, @@ -659,7 +685,7 @@ def catch_convergence_reinforce_grid( Reinforcement strategy to reinforce grids with non-converging time steps. First, conducts a grid reinforcement with only converging time steps. - Afterwards, tries to run reinforcement with all time steps that did not converge + Afterward, tries to run reinforcement with all time steps that did not converge in the beginning. At last, if there are still time steps that do not converge, the feed-in and load time series are iteratively scaled and the grid reinforced, starting with a low grid load and scaling-up the time series until the original @@ -739,14 +765,16 @@ def reinforce(): selected_timesteps = converging_timesteps reinforce() - # Run reinforcement for time steps that did not converge after initial reinforcement - if not non_converging_timesteps.empty: - logger.info( - "Run reinforcement for time steps that did not converge after initial " - "reinforcement." - ) - selected_timesteps = non_converging_timesteps - converged = reinforce() + # Run reinforcement for time steps that did not converge after initial + # reinforcement (only needs to done, when grid was previously reinforced using + # converged time steps, wherefore it is within that if-statement) + if not non_converging_timesteps.empty: + logger.info( + "Run reinforcement for time steps that did not converge after initial " + "reinforcement." + ) + selected_timesteps = non_converging_timesteps + converged = reinforce() if converged: return edisgo.results diff --git a/edisgo/io/db.py b/edisgo/io/db.py index 02d1320e8..19dfabcde 100644 --- a/edisgo/io/db.py +++ b/edisgo/io/db.py @@ -163,7 +163,7 @@ def engine(path: Path | str, ssh: bool = False) -> Engine: Returns ------- - sqlalchemy.engine.base.Engine + :sqlalchemy:`sqlalchemy.Engine` Database engine """ diff --git a/edisgo/tools/temporal_complexity_reduction.py b/edisgo/tools/temporal_complexity_reduction.py index 5dfc0090a..973d23b36 100644 --- a/edisgo/tools/temporal_complexity_reduction.py +++ b/edisgo/tools/temporal_complexity_reduction.py @@ -3,7 +3,6 @@ import logging import os -from copy import deepcopy from typing import TYPE_CHECKING import numpy as np @@ -18,55 +17,87 @@ logger = logging.getLogger(__name__) -def _scored_most_critical_loading(edisgo_obj: EDisGo) -> pd.Series: +def _scored_most_critical_loading( + edisgo_obj: EDisGo, weight_by_costs: bool = True +) -> pd.Series: """ - Method to get time steps where at least one component shows its highest overloading. + Get time steps sorted by severity of overloadings. + + The overloading can be weighted by the estimated expansion costs of each respective + line and transformer. See parameter `weight_by_costs` for more information. Parameters ----------- edisgo_obj : :class:`~.EDisGo` + weight_by_costs : bool + Defines whether overloading issues should be weighted by estimated grid + expansion costs or not. See parameter `weight_by_costs` in + :func:`~get_most_critical_time_steps` for more information. + Default: True. Returns -------- :pandas:`pandas.Series` Series with time index and corresponding sum of maximum relative overloadings - of lines and transformers. The series only contains time steps, where at least - one component is maximally overloaded, and is sorted descending by the - sum of maximum relative overloadings. + of lines and transformers (weighted by estimated reinforcement costs, in case + `weight_by_costs` is True). The series only contains time steps, where at least + one component is maximally overloaded, and is sorted descending order. """ - + # ToDo The relative loading is used in this function to determine most critical + # time steps. While this makes sense to determine which lines are overloaded, it + # is not the best indicator for the weighting as it does not convey the number + # of additional lines needed to solve a problem. For that the number of parallel + # standard lines and transformers needed would be better. However, for now + # using the relative overloading as an estimation is okay. # Get current relative to allowed current relative_i_res = check_tech_constraints.components_relative_load(edisgo_obj) # Get lines that have violations crit_lines_score = relative_i_res[relative_i_res > 1] - # Get most critical timesteps per component + # Get most critical time steps per component + crit_lines_score = crit_lines_score[crit_lines_score == crit_lines_score.max()] + + if weight_by_costs: + # weight line violations with expansion costs + costs = _costs_per_line_and_transformer(edisgo_obj) + crit_lines_score = crit_lines_score * costs.loc[crit_lines_score.columns] + else: + crit_lines_score = crit_lines_score - 1 + + # drop components and time steps without violations crit_lines_score = ( - (crit_lines_score[crit_lines_score == crit_lines_score.max()]) - .dropna(how="all") - .dropna(how="all", axis=1) + crit_lines_score.dropna(how="all").dropna(how="all", axis=1).fillna(0) ) - - # Sort according to highest cumulated relative overloading - crit_lines_score = (crit_lines_score - 1).sum(axis=1) - return crit_lines_score.sort_values(ascending=False) + # sort sum in descending order + return crit_lines_score.sum(axis=1).sort_values(ascending=False) -def _scored_most_critical_voltage_issues(edisgo_obj: EDisGo) -> pd.Series: +def _scored_most_critical_voltage_issues( + edisgo_obj: EDisGo, weight_by_costs: bool = True +) -> pd.Series: """ Method to get time steps where at least one bus shows its highest deviation from allowed voltage boundaries. + The voltage issues can be weighted by the estimated expansion costs in each + respective feeder. See parameter `weight_by_costs` for more information. + Parameters ----------- edisgo_obj : :class:`~.EDisGo` + weight_by_costs : bool + Defines whether voltage issues should be weighted by estimated grid expansion + costs or not. See parameter `weight_by_costs` in + :func:`~get_most_critical_time_steps` for more information. + Default: True. Returns -------- :pandas:`pandas.Series` - Series with time index and corresponding sum of maximum voltage deviations. + Series with time index and corresponding sum of maximum voltage deviations + (weighted by estimated reinforcement costs, in case `weight_by_costs` is True). The series only contains time steps, where at least one bus has its highest deviation from the allowed voltage limits, and is sorted descending by the sum of maximum voltage deviations. @@ -77,18 +108,42 @@ def _scored_most_critical_voltage_issues(edisgo_obj: EDisGo) -> pd.Series: ) # Get score for nodes that are over or under the allowed deviations - voltage_diff = voltage_diff.abs()[voltage_diff.abs() > 0] + voltage_diff = voltage_diff[voltage_diff != 0.0].abs() # get only most critical events for component # Todo: should there be different ones for over and undervoltage? - voltage_diff = ( - (voltage_diff[voltage_diff.abs() == voltage_diff.abs().max()]) - .dropna(how="all") - .dropna(how="all", axis=1) - ) - - voltage_diff = voltage_diff.sum(axis=1) + voltage_diff = voltage_diff[voltage_diff == voltage_diff.max()] + + if weight_by_costs: + # set feeder using MV feeder for MV components and LV feeder for LV components + edisgo_obj.topology.assign_feeders(mode="grid_feeder") + # feeders of buses at MV/LV station's secondary sides are set to the name of the + # station bus to have them as separate feeders + lv_station_buses = [ + lv_grid.station.index[0] for lv_grid in edisgo_obj.topology.mv_grid.lv_grids + ] + edisgo_obj.topology.buses_df.loc[ + lv_station_buses, "grid_feeder" + ] = lv_station_buses + # weight voltage violations with expansion costs + costs = _costs_per_feeder(edisgo_obj, lv_station_buses=lv_station_buses) + # map feeder costs to buses + feeder_buses = edisgo_obj.topology.buses_df.grid_feeder + costs_buses = pd.Series( + { + bus_name: ( + costs[feeder_buses[bus_name]] + if feeder_buses[bus_name] != "station_node" + else 0 + ) + for bus_name in feeder_buses.index + } + ) + voltage_diff = voltage_diff * costs_buses.loc[voltage_diff.columns] - return voltage_diff.sort_values(ascending=False) + # drop components and time steps without violations + voltage_diff = voltage_diff.dropna(how="all").dropna(how="all", axis=1).fillna(0) + # sort sum in descending order + return voltage_diff.sum(axis=1).sort_values(ascending=False) def _scored_most_critical_loading_time_interval( @@ -97,12 +152,13 @@ def _scored_most_critical_loading_time_interval( time_steps_per_day=24, time_step_day_start=0, overloading_factor=0.95, + weight_by_costs=True, ): """ Get time intervals sorted by severity of overloadings. - The overloading is weighed by the estimated expansion costs of each respective line - and transformer. + The overloading can be weighted by the estimated expansion costs of each respective + line and transformer. See parameter `weight_by_costs` for more information. The length of the time intervals and hour of day at which the time intervals should begin can be set through the parameters `time_steps_per_time_interval` and `time_step_day_start`. @@ -115,24 +171,29 @@ def _scored_most_critical_loading_time_interval( The eDisGo API object time_steps_per_time_interval : int Amount of continuous time steps in an interval that violation is determined for. - Currently, these can only be multiples of 24. + See parameter `time_steps_per_time_interval` in + :func:`~get_most_critical_time_intervals` for more information. Default: 168. time_steps_per_day : int - Number of time steps in one day. In case of an hourly resolution this is 24. - As currently only an hourly resolution is possible, this value should always be - 24. + Number of time steps in one day. See parameter `time_steps_per_day` in + :func:`~get_most_critical_time_intervals` for more information. Default: 24. time_step_day_start : int - Time step of the day at which each interval should start. If you want it to - start at midnight, this should be set to 0. Default: 0. + Time step of the day at which each interval should start. See parameter + `time_step_day_start` in :func:`~get_most_critical_time_intervals` for more + information. + Default: 0. overloading_factor : float Factor at which an overloading of a component is considered to be close enough - to the highest overloading of that component. This is used to determine the - number of components that reach their highest overloading in each time interval. - Per default, it is set to 0.95, which means that if the highest overloading of - a component is 2, it will be considered maximally overloaded at an overloading - of higher or equal to 2*0.95. + to the highest overloading of that component. See parameter + `overloading_factor` in :func:`~get_most_critical_time_intervals` for more + information. Default: 0.95. + weight_by_costs : bool + Defines whether overloading issues should be weighted by estimated grid + expansion costs or not. See parameter `weight_by_costs` in + :func:`~get_most_critical_time_intervals` for more information. + Default: True. Returns -------- @@ -153,70 +214,22 @@ def _scored_most_critical_loading_time_interval( # Get lines that have violations and replace nan values with 0 crit_lines_score = relative_i_res[relative_i_res > 1].fillna(0) - # weight line violations with expansion costs - costs_lines = ( - line_expansion_costs(edisgo_obj).drop(columns="voltage_level").sum(axis=1) - ) - costs_trafos_lv = pd.Series( - index=[ - str(lv_grid) + "_station" - for lv_grid in list(edisgo_obj.topology.mv_grid.lv_grids) - ], - data=edisgo_obj.config["costs_transformers"]["lv"], - ) - costs_trafos_mv = pd.Series( - index=["MVGrid_" + str(edisgo_obj.topology.id) + "_station"], - data=edisgo_obj.config["costs_transformers"]["mv"], - ) - costs = pd.concat([costs_lines, costs_trafos_lv, costs_trafos_mv]) - crit_lines_cost = crit_lines_score * costs - - # Get highest overloading in each window for each component and sum it up - crit_timesteps = ( - crit_lines_cost.rolling( - window=int(time_steps_per_time_interval), closed="right" - ) - .max() - .sum(axis=1) - ) - # select each nth time window to only consider windows starting at a certain time - # of day and sort time intervals in descending order - # ToDo: To make function work for frequencies other than hourly, the following - # needs to be adapted to index based on time index instead of iloc - crit_timesteps = ( - crit_timesteps.iloc[int(time_steps_per_time_interval) - 1 :] - .iloc[time_step_day_start + 1 :: time_steps_per_day] - .sort_values(ascending=False) - ) - # move time index as rolling gives the end of the time interval, but we want the - # beginning - timesteps = crit_timesteps.index - pd.DateOffset( - hours=int(time_steps_per_time_interval) - ) - time_intervals = [ - pd.date_range( - start=timestep, periods=int(time_steps_per_time_interval), freq="h" - ) - for timestep in timesteps - ] - - # make dataframe with time steps in each time interval and the percentage of - # components that reach their maximum overloading - time_intervals_df = pd.DataFrame( - index=range(len(time_intervals)), - columns=["time_steps", "percentage_max_overloaded_components"], + if weight_by_costs: + # weight line violations with expansion costs + costs = _costs_per_line_and_transformer(edisgo_obj) + crit_lines_weighted = crit_lines_score * costs + else: + crit_lines_weighted = crit_lines_score.copy() + + time_intervals_df = _most_critical_time_interval( + costs_per_time_step=crit_lines_weighted, + grid_issues_magnitude_df=crit_lines_score, + which="overloading", + deviation_factor=overloading_factor, + time_steps_per_time_interval=time_steps_per_time_interval, + time_steps_per_day=time_steps_per_day, + time_step_day_start=time_step_day_start, ) - time_intervals_df["time_steps"] = time_intervals - lines_no_max = crit_lines_score.columns.values - total_lines = len(lines_no_max) - max_per_line = crit_lines_score.max() - for i in range(len(time_intervals)): - # check if worst overloading of every line is included in time interval - max_per_line_ti = crit_lines_score.loc[time_intervals[i]].max() - time_intervals_df["percentage_max_overloaded_components"][i] = ( - len(max_per_line_ti[max_per_line_ti >= max_per_line * overloading_factor]) - / total_lines - ) return time_intervals_df @@ -227,12 +240,13 @@ def _scored_most_critical_voltage_issues_time_interval( time_steps_per_day=24, time_step_day_start=0, voltage_deviation_factor=0.95, + weight_by_costs=True, ): """ Get time intervals sorted by severity of voltage issues. - The voltage issues are weighed by the estimated expansion costs in each respective - feeder. + The voltage issues can be weighted by the estimated expansion costs in each + respective feeder. See parameter `weight_by_costs` for more information. The length of the time intervals and hour of day at which the time intervals should begin can be set through the parameters `time_steps_per_time_interval` and `time_step_day_start`. @@ -245,25 +259,29 @@ def _scored_most_critical_voltage_issues_time_interval( The eDisGo API object time_steps_per_time_interval : int Amount of continuous time steps in an interval that violation is determined for. - Currently, these can only be multiples of 24. + See parameter `time_steps_per_time_interval` in + :func:`~get_most_critical_time_intervals` for more information. Default: 168. time_steps_per_day : int - Number of time steps in one day. In case of an hourly resolution this is 24. - As currently only an hourly resolution is possible, this value should always be - 24. + Number of time steps in one day. See parameter `time_steps_per_day` in + :func:`~get_most_critical_time_intervals` for more information. Default: 24. time_step_day_start : int - Time step of the day at which each interval should start. If you want it to - start at midnight, this should be set to 0. Default: 0. + Time step of the day at which each interval should start. See parameter + `time_step_day_start` in :func:`~get_most_critical_time_intervals` for more + information. + Default: 0. voltage_deviation_factor : float Factor at which a voltage deviation at a bus is considered to be close enough - to the highest voltage deviation at that bus. This is used to determine the - number of buses that reach their highest voltage deviation in each time - interval. Per default, it is set to 0.95. This means that if the highest voltage - deviation at a bus is 0.2, it will be included in the determination of number - of buses that reach their maximum voltage deviation in a certain time interval - at a voltage deviation of higher or equal to 0.2*0.95. + to the highest voltage deviation at that bus. See parameter + `voltage_deviation_factor` in :func:`~get_most_critical_time_intervals` for more + information. Default: 0.95. + weight_by_costs : bool + Defines whether voltage issues should be weighted by estimated grid expansion + costs or not. See parameter `weight_by_costs` in + :func:`~get_most_critical_time_intervals` for more information. + Default: True. Returns -------- @@ -280,31 +298,122 @@ def _scored_most_critical_voltage_issues_time_interval( """ - # Get voltage deviation from allowed voltage limits + # get voltage deviation from allowed voltage limits voltage_diff = check_tech_constraints.voltage_deviation_from_allowed_voltage_limits( edisgo_obj ) - voltage_diff = voltage_diff.abs()[voltage_diff.abs() > 0] + voltage_diff = voltage_diff[voltage_diff != 0.0].abs().fillna(0) - # determine costs per feeder + # set feeder using MV feeder for MV components and LV feeder for LV components + edisgo_obj.topology.assign_feeders(mode="grid_feeder") + # feeders of buses at MV/LV station's secondary sides are set to the name of the + # station bus to have them as separate feeders lv_station_buses = [ lv_grid.station.index[0] for lv_grid in edisgo_obj.topology.mv_grid.lv_grids ] + edisgo_obj.topology.buses_df.loc[lv_station_buses, "grid_feeder"] = lv_station_buses + + # check for every feeder if any of the buses within violate the allowed voltage + # deviation, by grouping voltage_diff per feeder + feeder_buses = edisgo_obj.topology.buses_df.grid_feeder + columns = [feeder_buses.loc[col] for col in voltage_diff.columns] + voltage_diff_feeder = voltage_diff.copy() + voltage_diff_feeder.columns = columns + voltage_diff_feeder = ( + voltage_diff.transpose().reset_index().groupby(by="Bus").max().transpose() + ) + + if weight_by_costs: + # get costs per feeder + costs_per_feeder = _costs_per_feeder(edisgo_obj, lv_station_buses) + # weight feeder voltage violation with costs per feeder + voltage_diff_feeder = voltage_diff_feeder * costs_per_feeder + + time_intervals_df = _most_critical_time_interval( + costs_per_time_step=voltage_diff_feeder, + grid_issues_magnitude_df=voltage_diff, + which="voltage", + deviation_factor=voltage_deviation_factor, + time_steps_per_time_interval=time_steps_per_time_interval, + time_steps_per_day=time_steps_per_day, + time_step_day_start=time_step_day_start, + ) + + return time_intervals_df + + +def _costs_per_line_and_transformer(edisgo_obj): + """ + Helper function to get costs per line (including earthwork and costs for one new + line) and per transformer. + + Transformers are named after the grid at the lower voltage level and with the + expansion "_station", e.g. "LVGrid_0_station". + + Returns + ------- + :pandas:`pandas.Series` + Series with component name in index and costs in kEUR as values. + + """ costs_lines = ( line_expansion_costs(edisgo_obj).drop(columns="voltage_level").sum(axis=1) ) costs_trafos_lv = pd.Series( - index=lv_station_buses, - data=edisgo_obj.config._data["costs_transformers"]["lv"], + index=[ + str(lv_grid) + "_station" + for lv_grid in list(edisgo_obj.topology.mv_grid.lv_grids) + ], + data=edisgo_obj.config["costs_transformers"]["lv"], ) - costs = pd.concat([costs_lines, costs_trafos_lv]) + costs_trafos_mv = pd.Series( + index=["MVGrid_" + str(edisgo_obj.topology.id) + "_station"], + data=edisgo_obj.config["costs_transformers"]["mv"], + ) + return pd.concat([costs_lines, costs_trafos_lv, costs_trafos_mv]) + + +def _costs_per_feeder(edisgo_obj, lv_station_buses=None): + """ + Helper function to get costs per MV and LV feeder (including earthwork and costs for + one new line) and per MV/LV transformer (as they are considered as feeders). + + Transformers are named after the bus at the MV/LV station's secondary side. + + Parameters + ----------- + edisgo_obj : :class:`~.EDisGo` + lv_station_buses : list(str) or None + List of bus names of buses at the secondary side of the MV/LV transformers. + If None, list is generated. + + Returns + ------- + :pandas:`pandas.Series` + Series with feeder names in index and costs in kEUR as values. + + """ + if lv_station_buses is None: + lv_station_buses = [ + lv_grid.station.index[0] for lv_grid in edisgo_obj.topology.mv_grid.lv_grids + ] + if "grid_feeder" not in edisgo_obj.topology.buses_df.columns: + # set feeder using MV feeder for MV components and LV feeder for LV components + edisgo_obj.topology.assign_feeders(mode="grid_feeder") - # set feeder using MV feeder for MV components and LV feeder for LV components - edisgo_obj.topology.assign_feeders(mode="grid_feeder") # feeders of buses at MV/LV station's secondary sides are set to the name of the # station bus to have them as separate feeders edisgo_obj.topology.buses_df.loc[lv_station_buses, "grid_feeder"] = lv_station_buses + costs_lines = ( + line_expansion_costs(edisgo_obj).drop(columns="voltage_level").sum(axis=1) + ) + costs_trafos_lv = pd.Series( + index=lv_station_buses, + data=edisgo_obj.config._data["costs_transformers"]["lv"], + ) + costs = pd.concat([costs_lines, costs_trafos_lv]) + feeder_lines = edisgo_obj.topology.lines_df.grid_feeder feeder_trafos_lv = pd.Series( index=lv_station_buses, @@ -317,23 +426,82 @@ def _scored_most_critical_voltage_issues_time_interval( .sum() ) - # check for every feeder if any of the buses within violate the allowed voltage - # deviation, by grouping voltage_diff per feeder - feeder_buses = edisgo_obj.topology.buses_df.grid_feeder - columns = [feeder_buses.loc[col] for col in voltage_diff.columns] - voltage_diff_copy = deepcopy(voltage_diff).fillna(0) - voltage_diff.columns = columns - voltage_diff_feeder = ( - voltage_diff.transpose().reset_index().groupby(by="index").sum().transpose() - ) - voltage_diff_feeder[voltage_diff_feeder != 0] = 1 + return costs_per_feeder.squeeze() + - # weigh feeder voltage violation with costs per feeder - voltage_diff_feeder = voltage_diff_feeder * costs_per_feeder.squeeze() +def _most_critical_time_interval( + costs_per_time_step, + grid_issues_magnitude_df, + which, + deviation_factor=0.95, + time_steps_per_time_interval=168, + time_steps_per_day=24, + time_step_day_start=0, +): + """ + Helper function used in functions + :func:`~_scored_most_critical_loading_time_interval` and + :func:`~_scored_most_critical_voltage_issues_time_interval` + to get time intervals sorted by severity of grid issue. + + This function currently only works for an hourly resolution! - # Get the highest voltage issues in each window for each feeder and sum it up + Parameters + ----------- + costs_per_time_step : :pandas:`pandas.DataFrame` + Dataframe containing the estimated grid expansion costs per line or feeder. + Columns contain line or feeder names. + Index of the dataframe are all time steps power flow analysis + was conducted for of type :pandas:`pandas.Timestamp`. + grid_issues_magnitude_df : :pandas:`pandas.DataFrame` + Dataframe containing the relative overloading or voltage deviation per time + step in case of an overloading or voltage issue in that time step. + Columns contain line or bus names. + Index of the dataframe are all time steps power flow analysis + was conducted for of type :pandas:`pandas.Timestamp`. + which : str + Defines whether function is used to determine most critical time intervals for + voltage or overloading problems. Can either be "voltage" or "overloading". + deviation_factor : float + Factor at which a grid issue is considered to be close enough to the highest + grid issue. In case parameter `which` is "voltage", see parameter + `voltage_deviation_factor` in :func:`~get_most_critical_time_intervals` for more + information. In case parameter `which` is "overloading", see parameter + `overloading_factor` in :func:`~get_most_critical_time_intervals` for more + information. + Default: 0.95. + time_steps_per_time_interval : int + Amount of continuous time steps in an interval that violation is determined for. + See parameter `time_steps_per_time_interval` in + :func:`~get_most_critical_time_intervals` for more information. + Default: 168. + time_steps_per_day : int + Number of time steps in one day. See parameter `time_steps_per_day` in + :func:`~get_most_critical_time_intervals` for more information. + Default: 24. + time_step_day_start : int + Time step of the day at which each interval should start. See parameter + `time_step_day_start` in :func:`~get_most_critical_time_intervals` for more + information. + Default: 0. + + Returns + -------- + :pandas:`pandas.DataFrame` + Contains time intervals in which grid expansion needs due to voltage issues + are detected. The time intervals are sorted descending + by the expected cumulated grid expansion costs, so that the time interval with + the highest expected costs corresponds to index 0. The time steps in the + respective time interval are given in column "time_steps" and the share + of buses for which the maximum voltage deviation is reached during the time + interval is given in column "percentage_buses_max_voltage_deviation". Each bus + is only considered once. That means if its maximum voltage deviation was + already considered in an earlier time interval, it is not considered again. + + """ + # get the highest issues in each window for each feeder and sum it up crit_timesteps = ( - voltage_diff_feeder.rolling( + costs_per_time_step.rolling( window=int(time_steps_per_time_interval), closed="right" ) .max() @@ -345,44 +513,51 @@ def _scored_most_critical_voltage_issues_time_interval( # needs to be adapted to index based on time index instead of iloc crit_timesteps = ( crit_timesteps.iloc[int(time_steps_per_time_interval) - 1 :] - .iloc[time_step_day_start + 1 :: time_steps_per_day] + .iloc[time_step_day_start::time_steps_per_day] .sort_values(ascending=False) ) - timesteps = crit_timesteps.index - pd.DateOffset( - hours=int(time_steps_per_time_interval) - ) + # get time steps in each time interval - these are set up setting the given time + # step to be the end of the respective time interval, as rolling() function gives + # the time step at the end of the considered time interval; further, only time + # intervals with a sum greater than zero are considered, as zero values mean, that + # there is no grid issue in the respective time interval time_intervals = [ - pd.date_range( - start=timestep, periods=int(time_steps_per_time_interval), freq="h" - ) - for timestep in timesteps + pd.date_range(end=timestep, periods=int(time_steps_per_time_interval), freq="h") + for timestep in crit_timesteps.index + if crit_timesteps[timestep] != 0.0 ] # make dataframe with time steps in each time interval and the percentage of - # buses that reach their maximum voltage deviation + # buses/branches that reach their maximum voltage deviation / overloading + if which == "voltage": + percentage = "percentage_buses_max_voltage_deviation" + else: + percentage = "percentage_max_overloaded_components" time_intervals_df = pd.DataFrame( index=range(len(time_intervals)), - columns=["time_steps", "percentage_buses_max_voltage_deviation"], + columns=["time_steps", percentage], ) time_intervals_df["time_steps"] = time_intervals - max_per_bus = voltage_diff_copy.max().fillna(0) - buses_no_max = max_per_bus.index.values - total_buses = len(buses_no_max) + max_per_bus = grid_issues_magnitude_df.max().fillna(0) + total_buses = len(grid_issues_magnitude_df.columns) for i in range(len(time_intervals)): # check if worst voltage deviation of every bus is included in time interval - max_per_bus_ti = voltage_diff_copy.loc[time_intervals[i]].max() - time_intervals_df["percentage_buses_max_voltage_deviation"][i] = ( - len( - max_per_bus_ti[max_per_bus_ti >= max_per_bus * voltage_deviation_factor] - ) + max_per_bus_ti = grid_issues_magnitude_df.loc[time_intervals[i]].max() + time_intervals_df[percentage][i] = ( + len(max_per_bus_ti[max_per_bus_ti >= max_per_bus * deviation_factor]) / total_buses ) - return time_intervals_df -def _troubleshooting_mode(edisgo_obj): +def _troubleshooting_mode( + edisgo_obj, + mode=None, + timesteps=None, + lv_grid_id=None, + scale_timeseries=None, +): """ Handles non-convergence issues in power flow by iteratively reducing load and feed-in until the power flow converges. @@ -390,10 +565,36 @@ def _troubleshooting_mode(edisgo_obj): Load and feed-in is reduced in steps of 10% down to 20% of the original load and feed-in. The most critical time intervals / time steps can then be determined based on the power flow results with the reduced load and feed-in. + + Parameters + ----------- + edisgo_obj : :class:`~.EDisGo` + The eDisGo API object + mode : str or None + Allows to toggle between power flow analysis for the whole network or just + the MV or one LV grid. See parameter `mode` in function + :attr:`~.EDisGo.analyze` for more information. + timesteps : :pandas:`pandas.DatetimeIndex` or \ + :pandas:`pandas.Timestamp` + Timesteps specifies from which time steps to select most critical ones. It + defaults to None in which case all time steps in + :attr:`~.network.timeseries.TimeSeries.timeindex` are used. + lv_grid_id : int or str + ID (e.g. 1) or name (string representation, e.g. "LVGrid_1") of LV grid + to analyze in case mode is 'lv'. Default: None. + scale_timeseries : float or None + See parameter `scale_timeseries` in function :attr:`~.EDisGo.analyze` for more + information. + """ try: logger.debug("Running initial power flow for temporal complexity reduction.") - edisgo_obj.analyze() + edisgo_obj.analyze( + mode=mode, + timesteps=timesteps, + lv_grid_id=lv_grid_id, + scale_timeseries=scale_timeseries, + ) # Exception is used, as non-convergence can also lead to RuntimeError, not only # ValueError except Exception: @@ -404,12 +605,17 @@ def _troubleshooting_mode(edisgo_obj): "not all time steps converged. Power flow is run again with reduced " "network load." ) - for fraction in np.arange(0.8, 0.0, step=-0.1): + if isinstance(scale_timeseries, float): + iter_start = scale_timeseries - 0.1 + else: + iter_start = 0.8 + for fraction in np.arange(iter_start, 0.0, step=-0.1): try: edisgo_obj.analyze( - troubleshooting_mode="iteration", - range_start=fraction, - range_num=1, + mode=mode, + timesteps=timesteps, + lv_grid_id=lv_grid_id, + scale_timeseries=fraction, ) logger.info( f"Power flow fully converged for a reduction factor " @@ -442,11 +648,12 @@ def get_most_critical_time_intervals( use_troubleshooting_mode=True, overloading_factor=0.95, voltage_deviation_factor=0.95, + weight_by_costs=True, ): """ Get time intervals sorted by severity of overloadings as well as voltage issues. - The overloading and voltage issues are weighed by the estimated expansion costs + The overloading and voltage issues can be weighted by the estimated expansion costs solving the issue would require. The length of the time intervals and hour of day at which the time intervals should begin can be set through the parameters `time_steps_per_time_interval` and @@ -503,6 +710,33 @@ def get_most_critical_time_intervals( of buses that reach their maximum voltage deviation in a certain time interval at a voltage deviation of higher or equal to 0.2*0.95. Default: 0.95. + weight_by_costs : bool + Defines whether overloading and voltage issues should be weighted by estimated + grid expansion costs or not. This can be done in order to take into account that + some grid issues are more relevant, as reinforcing a certain line or feeder will + be more expensive than another one. + + In case of voltage issues: + If True, the costs for each MV and LV feeder, as well as MV/LV station are + determined using the costs for earth work and new lines over the full length of + the feeder respectively for a new MV/LV station. In each time interval, the + estimated costs are only taken into account, in case there is a voltage issue + somewhere in the feeder. + The costs don't convey the actual costs but are an estimation, as + the real number of parallel lines needed is not determined and the whole feeder + length is used instead of the length over two-thirds of the feeder. + If False, only the maximum voltage deviation in the feeder is used to determine + the most relevant time intervals. + + In case of overloading issues: + If True, the overloading of each line is multiplied by the respective grid + expansion costs of that line including costs for earth work and one new line. + The costs don't convey the actual costs but are an estimation, as + the discrete number of needed parallel lines is not considered. + If False, only the relative overloading is used to determine the most relevant + time intervals. + + Default: True. Returns -------- @@ -544,6 +778,7 @@ def get_most_critical_time_intervals( time_steps_per_time_interval, time_step_day_start=time_step_day_start, overloading_factor=overloading_factor, + weight_by_costs=weight_by_costs, ) if num_time_intervals is None: num_time_intervals = int(np.ceil(len(loading_scores) * percentage)) @@ -564,6 +799,7 @@ def get_most_critical_time_intervals( time_steps_per_time_interval, time_step_day_start=time_step_day_start, voltage_deviation_factor=voltage_deviation_factor, + weight_by_costs=weight_by_costs, ) if num_time_intervals is None: num_time_intervals = int(np.ceil(len(voltage_scores) * percentage)) @@ -601,10 +837,16 @@ def get_most_critical_time_intervals( def get_most_critical_time_steps( edisgo_obj: EDisGo, + mode=None, + timesteps=None, + lv_grid_id=None, + scale_timeseries=None, num_steps_loading=None, num_steps_voltage=None, percentage: float = 1.0, use_troubleshooting_mode=True, + run_initial_analyze=True, + weight_by_costs=True, ) -> pd.DatetimeIndex: """ Get the time steps with the most critical overloading and voltage issues. @@ -613,6 +855,21 @@ def get_most_critical_time_steps( ----------- edisgo_obj : :class:`~.EDisGo` The eDisGo API object + mode : str or None + Allows to toggle between power flow analysis for the whole network or just + the MV or one LV grid. See parameter `mode` in function + :attr:`~.EDisGo.analyze` for more information. + timesteps : :pandas:`pandas.DatetimeIndex` or \ + :pandas:`pandas.Timestamp` + Timesteps specifies from which time steps to select most critical ones. It + defaults to None in which case all time steps in + :attr:`~.network.timeseries.TimeSeries.timeindex` are used. + lv_grid_id : int or str + ID (e.g. 1) or name (string representation, e.g. "LVGrid_1") of LV grid + to analyze in case mode is 'lv'. Default: None. + scale_timeseries : float or None + See parameter `scale_timeseries` in function :attr:`~.EDisGo.analyze` for more + information. num_steps_loading : int The number of most critical overloading events to select. If None, `percentage` is used. Default: None. @@ -630,6 +887,35 @@ def get_most_critical_time_steps( are then determined based on the power flow results with the reduced load and feed-in. If False, an error will be raised in case time steps do not converge. Default: True. + run_initial_analyze : bool + This parameter can be used to specify whether to run an initial analyze to + determine most critical time steps or to use existing results. If set to False, + `use_troubleshooting_mode` is ignored. Default: True. + weight_by_costs : bool + Defines whether overloading and voltage issues should be weighted by estimated + grid expansion costs or not. This can be done in order to take into account that + some grid issues are more relevant, as reinforcing a certain line or feeder will + be more expensive than another one. + + In case of voltage issues: + If True, the voltage issues at each bus are weighted by the estimated grid + expansion costs for the MV or LV feeder the bus is in or in case of MV/LV + stations by the costs for a new transformer. Feeder costs are determined using + the costs for earth work and new lines over the full length of the feeder. + The costs don't convey the actual costs but are an estimation, as + the real number of parallel lines needed is not determined and the whole feeder + length is used instead of the length over two-thirds of the feeder. + If False, the severity of each feeder's voltage issue is set to be the same. + + In case of overloading issues: + If True, the overloading of each line is multiplied by + the respective grid expansion costs of that line including costs for earth work + and one new line. + The costs don't convey the actual costs but are an estimation, as + the discrete needed number of parallel lines is not considered. + If False, only the relative overloading is used. + + Default: True. Returns -------- @@ -639,14 +925,30 @@ def get_most_critical_time_steps( """ # Run power flow - if use_troubleshooting_mode: - edisgo_obj = _troubleshooting_mode(edisgo_obj) - else: - logger.debug("Running initial power flow for temporal complexity reduction.") - edisgo_obj.analyze() + if run_initial_analyze: + if use_troubleshooting_mode: + edisgo_obj = _troubleshooting_mode( + edisgo_obj, + mode=mode, + timesteps=timesteps, + lv_grid_id=lv_grid_id, + scale_timeseries=scale_timeseries, + ) + else: + logger.debug( + "Running initial power flow for temporal complexity reduction." + ) + edisgo_obj.analyze( + mode=mode, + timesteps=timesteps, + lv_grid_id=lv_grid_id, + scale_timeseries=scale_timeseries, + ) # Select most critical steps based on current violations - loading_scores = _scored_most_critical_loading(edisgo_obj) + loading_scores = _scored_most_critical_loading( + edisgo_obj, weight_by_costs=weight_by_costs + ) if num_steps_loading is None: num_steps_loading = int(len(loading_scores) * percentage) else: @@ -658,10 +960,18 @@ def get_most_critical_time_steps( f"{len(loading_scores)} time steps are exported." ) num_steps_loading = len(loading_scores) + elif num_steps_loading < len(loading_scores): + logger.info( + f"{num_steps_loading} of a total of {len(loading_scores)} relevant " + f"time steps for overloading issues are chosen for the selection " + f"of most critical time steps." + ) steps = loading_scores[:num_steps_loading].index # Select most critical steps based on voltage violations - voltage_scores = _scored_most_critical_voltage_issues(edisgo_obj) + voltage_scores = _scored_most_critical_voltage_issues( + edisgo_obj, weight_by_costs=weight_by_costs + ) if num_steps_voltage is None: num_steps_voltage = int(len(voltage_scores) * percentage) else: @@ -673,6 +983,12 @@ def get_most_critical_time_steps( f"{len(voltage_scores)} time steps are exported." ) num_steps_voltage = len(voltage_scores) + elif num_steps_voltage < len(voltage_scores): + logger.info( + f"{num_steps_voltage} of a total of {len(voltage_scores)} relevant " + f"time steps for voltage issues are chosen for the selection " + f"of most critical time steps." + ) steps = steps.append(voltage_scores[:num_steps_voltage].index) if len(steps) == 0: diff --git a/edisgo/tools/tools.py b/edisgo/tools/tools.py index ead8e08d2..d05fe1b86 100644 --- a/edisgo/tools/tools.py +++ b/edisgo/tools/tools.py @@ -1119,7 +1119,7 @@ def reduce_memory_usage(df: pd.DataFrame, show_reduction: bool = False) -> pd.Da be reduced to a smaller data type. Source: - https://www.mikulskibartosz.name/how-to-reduce-memory-usage-in-pandas/ + https://mikulskibartosz.name/how-to-reduce-memory-usage-in-pandas Parameters ---------- diff --git a/examples/edisgo_simple_example.ipynb b/examples/edisgo_simple_example.ipynb index b0a68fc63..c7ee79ce1 100644 --- a/examples/edisgo_simple_example.ipynb +++ b/examples/edisgo_simple_example.ipynb @@ -112,7 +112,7 @@ "Currently, synthetic grid data generated with the python project\n", "[ding0](https://github.com/openego/ding0)\n", "is the only supported data source for distribution grid data. ding0 provides the grid topology data in the form of csv files, with separate files for buses, lines, loads, generators, etc. You can retrieve ding0 data from\n", - "[Zenodo](https://zenodo.org/record/890479)\n", + "[Zenodo](https://zenodo.org/records/890479)\n", "(make sure you choose latest data) or check out the\n", "[Ding0 documentation](https://dingo.readthedocs.io/en/dev/usage_details.html#ding0-examples)\n", "on how to generate grids yourself. A ding0 example grid can be viewed [here](https://github.com/openego/eDisGo/tree/dev/tests/data/ding0_test_network_2). It is possible to provide your own grid data if it is in the same format as the ding0 grid data. \n", diff --git a/setup.py b/setup.py index e28c14695..4f570f745 100644 --- a/setup.py +++ b/setup.py @@ -44,7 +44,9 @@ def read(fname): "matplotlib >= 3.3.0", "multiprocess", "networkx >= 2.5.0", - "pandas >= 1.4.0", + # newer pandas versions don't work with specified sqlalchemy versions, but upgrading + # sqlalchemy leads to new errors.. should be fixed at some point + "pandas >= 1.4.0, < 2.2.0", "plotly", "pydot", "pygeos", diff --git a/tests/flex_opt/test_reinforce_grid.py b/tests/flex_opt/test_reinforce_grid.py index dd4ca4cb4..cb6074310 100644 --- a/tests/flex_opt/test_reinforce_grid.py +++ b/tests/flex_opt/test_reinforce_grid.py @@ -58,12 +58,10 @@ def test_reinforce_grid(self): # test reduced analysis res_reduced = reinforce_grid( edisgo=copy.deepcopy(self.edisgo), - timesteps_pfa="reduced_analysis", - num_steps_loading=4, - ) - assert_frame_equal( - res_reduced.equipment_changes, results_dict[None].equipment_changes + reduced_analysis=True, + num_steps_loading=2, ) + assert len(res_reduced.i_res) == 2 def test_run_separate_lv_grids(self): edisgo = copy.deepcopy(self.edisgo) diff --git a/tests/test_edisgo.py b/tests/test_edisgo.py index e2642bf9a..1f09e62d9 100755 --- a/tests/test_edisgo.py +++ b/tests/test_edisgo.py @@ -430,6 +430,7 @@ def test_analyze(self, caplog): assert "Current fraction in iterative process: 1.0." in caplog.text def test_reinforce(self): + # ToDo add tests to check content of equipment_changes # ###################### test with default settings ########################## self.setup_worst_case_time_series() results = self.edisgo.reinforce() @@ -546,6 +547,17 @@ def test_enhanced_reinforce_grid(self): assert len(results.equipment_changes) == 892 assert results.v_res.shape == (4, 148) + edisgo_obj = copy.deepcopy(self.edisgo) + edisgo_obj = enhanced_reinforce_grid( + edisgo_obj, + reduced_analysis=True, + is_worst_case=False, + separate_lv_grids=True, + num_steps_loading=1, + num_steps_voltage=1, + ) + assert edisgo_obj.results.v_res.shape == (2, 162) + def test_add_component(self, caplog): self.setup_worst_case_time_series() index = self.edisgo.timeseries.timeindex diff --git a/tests/tools/test_temporal_complexity_reduction.py b/tests/tools/test_temporal_complexity_reduction.py index 462e4c152..0fcbc7356 100644 --- a/tests/tools/test_temporal_complexity_reduction.py +++ b/tests/tools/test_temporal_complexity_reduction.py @@ -7,7 +7,7 @@ class TestTemporalComplexityReduction: - @classmethod + @pytest.fixture(autouse=True) def setup_class(self): self.edisgo = EDisGo(ding0_grid=pytest.ding0_test_network_path) self.edisgo.set_time_series_worst_case_analysis() @@ -32,32 +32,75 @@ def setup_class(self): self.edisgo.analyze() def test__scored_most_critical_loading(self): - ts_crit = temp_red._scored_most_critical_loading(self.edisgo) - + ts_crit = temp_red._scored_most_critical_loading( + self.edisgo, weight_by_costs=False + ) assert len(ts_crit) == 180 assert np.isclose(ts_crit.iloc[0], 1.45613) assert np.isclose(ts_crit.iloc[-1], 1.14647) - def test__scored_most_critical_voltage_issues(self): - ts_crit = temp_red._scored_most_critical_voltage_issues(self.edisgo) + ts_crit = temp_red._scored_most_critical_loading(self.edisgo) + assert len(ts_crit) == 180 + assert np.isclose(ts_crit.iloc[0], 190.63611) + assert np.isclose(ts_crit.iloc[-1], 48.13501) + + def test__scored_most_critical_voltage_issues(self): + ts_crit = temp_red._scored_most_critical_voltage_issues( + self.edisgo, weight_by_costs=False + ) assert len(ts_crit) == 120 assert np.isclose(ts_crit.iloc[0], 0.01062258) assert np.isclose(ts_crit.iloc[-1], 0.01062258) + ts_crit = temp_red._scored_most_critical_voltage_issues(self.edisgo) + assert len(ts_crit) == 120 + assert np.isclose(ts_crit.iloc[0], 0.1062258) + assert np.isclose(ts_crit.iloc[-1], 0.1062258) + def test_get_most_critical_time_steps(self): ts_crit = temp_red.get_most_critical_time_steps( - self.edisgo, num_steps_loading=2, num_steps_voltage=2 + self.edisgo, + num_steps_loading=2, + num_steps_voltage=2, + weight_by_costs=False, + run_initial_analyze=False, ) assert len(ts_crit) == 3 + ts_crit = temp_red.get_most_critical_time_steps( + self.edisgo, + num_steps_loading=2, + num_steps_voltage=2, + timesteps=self.edisgo.timeseries.timeindex[:24], + ) + assert len(ts_crit) == 2 + + ts_crit = temp_red.get_most_critical_time_steps( + self.edisgo, + mode="lv", + lv_grid_id=2, + percentage=0.5, + num_steps_voltage=2, + ) + assert len(ts_crit) == 0 + + ts_crit = temp_red.get_most_critical_time_steps( + self.edisgo, + mode="lv", + lv_grid_id=6, + percentage=0.5, + num_steps_voltage=2, + ) + assert len(ts_crit) == 60 + def test__scored_most_critical_loading_time_interval(self): # test with default values ts_crit = temp_red._scored_most_critical_loading_time_interval(self.edisgo, 24) - assert len(ts_crit) == 9 + assert len(ts_crit) == 10 assert ( ts_crit.loc[0, "time_steps"] - == pd.date_range("1/5/2018", periods=24, freq="H") + == pd.date_range("1/8/2018", periods=24, freq="H") ).all() assert np.isclose( ts_crit.loc[0, "percentage_max_overloaded_components"], 0.96479 @@ -77,36 +120,66 @@ def test__scored_most_critical_loading_time_interval(self): ).all() assert ts_crit.loc[0, "percentage_max_overloaded_components"] == 1 + # test without weighting by costs + ts_crit = temp_red._scored_most_critical_loading_time_interval( + self.edisgo, + 48, + weight_by_costs=False, + ) + assert len(ts_crit) == 9 + assert ( + ts_crit.loc[0, "time_steps"] + == pd.date_range("1/5/2018 0:00", periods=48, freq="H") + ).all() + def test__scored_most_critical_voltage_issues_time_interval(self): # test with default values ts_crit = temp_red._scored_most_critical_voltage_issues_time_interval( self.edisgo, 24 ) - assert len(ts_crit) == 9 + assert len(ts_crit) == 5 assert ( ts_crit.loc[0, "time_steps"] == pd.date_range("1/1/2018", periods=24, freq="H") ).all() - assert np.isclose(ts_crit.loc[0, "percentage_buses_max_voltage_deviation"], 1.0) - assert np.isclose(ts_crit.loc[1, "percentage_buses_max_voltage_deviation"], 1.0) + assert ( + ts_crit.loc[:, "percentage_buses_max_voltage_deviation"].values == 1.0 + ).all() # test with non-default values ts_crit = temp_red._scored_most_critical_voltage_issues_time_interval( - self.edisgo, 24, time_step_day_start=4, voltage_deviation_factor=0.5 + self.edisgo, 72, time_step_day_start=4, weight_by_costs=False ) - assert len(ts_crit) == 9 + assert len(ts_crit) == 5 assert ( ts_crit.loc[0, "time_steps"] - == pd.date_range("1/1/2018 4:00", periods=24, freq="H") + == pd.date_range("1/1/2018 4:00", periods=72, freq="H") ).all() - assert np.isclose(ts_crit.loc[0, "percentage_buses_max_voltage_deviation"], 1.0) + + def test__costs_per_line_and_transformer(self): + costs = temp_red._costs_per_line_and_transformer(self.edisgo) + assert len(costs) == 131 + 11 + assert np.isclose(costs["Line_10007"], 0.722445826838636 * 80) + assert np.isclose(costs["LVGrid_1_station"], 10) + + def test__costs_per_feeder(self): + costs = temp_red._costs_per_feeder(self.edisgo) + assert len(costs) == 37 + assert np.isclose(costs["Bus_BranchTee_MVGrid_1_1"], 295.34795) + assert np.isclose(costs["BusBar_MVGrid_1_LVGrid_1_LV"], 10) def test_get_most_critical_time_intervals(self): - self.edisgo.timeseries.timeindex = self.edisgo.timeseries.timeindex[:25] - self.edisgo.timeseries.scale_timeseries(p_scaling_factor=5, q_scaling_factor=5) + self.edisgo.timeseries.scale_timeseries(p_scaling_factor=2, q_scaling_factor=2) steps = temp_red.get_most_critical_time_intervals( - self.edisgo, time_steps_per_time_interval=24 + self.edisgo, time_steps_per_time_interval=24, percentage=0.5 ) - assert len(steps) == 1 - assert len(steps.columns) == 4 + assert len(steps) == 5 + assert ( + steps.loc[0, "time_steps_overloading"] + == pd.date_range("1/8/2018", periods=24, freq="H") + ).all() + assert ( + steps.loc[0, "time_steps_voltage_issues"] + == pd.date_range("1/1/2018", periods=24, freq="H") + ).all()