diff --git a/doc/api/edisgo.tools.rst b/doc/api/edisgo.tools.rst index 2c268568c..1bc306a87 100644 --- a/doc/api/edisgo.tools.rst +++ b/doc/api/edisgo.tools.rst @@ -9,14 +9,6 @@ edisgo.tools.config module :undoc-members: :show-inheritance: -edisgo.tools.edisgo\_run module --------------------------------- - -.. automodule:: edisgo.tools.edisgo_run - :members: - :undoc-members: - :show-inheritance: - edisgo.tools.geo module ------------------------ @@ -33,6 +25,14 @@ edisgo.tools.geopandas\_helper module :undoc-members: :show-inheritance: +edisgo.tools.logger module +---------------------------------------- + +.. automodule:: edisgo.tools.logger + :members: + :undoc-members: + :show-inheritance: + edisgo.tools.networkx\_helper module ---------------------------------------- diff --git a/doc/conf.py b/doc/conf.py index fa800e92f..688f95197 100644 --- a/doc/conf.py +++ b/doc/conf.py @@ -92,7 +92,7 @@ "shapely": ("https://shapely.readthedocs.io/en/latest/manual.html#%s", "shapely."), "ding0": ("https://dingo.readthedocs.io/en/dev/api/ding0.html#%s", "Ding0"), "pypsa": ("https://pypsa.readthedocs.io/en/latest/components.html#%s", "pypsa"), - "plotly": ("https://plotly.com/python-api-reference/generated/#%s.html", "plotly"), + "plotly": ("https://plotly.com/python-api-reference/generated/%s.html", "plotly"), } # Add any paths that contain templates here, relative to this directory. templates_path = ["_templates"] diff --git a/doc/whatsnew/v0-2-0.rst b/doc/whatsnew/v0-2-0.rst index aaabb5685..4a2d8af6a 100644 --- a/doc/whatsnew/v0-2-0.rst +++ b/doc/whatsnew/v0-2-0.rst @@ -9,11 +9,13 @@ Changes * added pre-commit hooks (flake8, black, isort, pyupgrade) `#229 `_ * added issue and pull request templates `#220 `_ * added Windows installation yml and documentation +* added functionality to set up different loggers with individual logging levels and where to write output `#295 `_ * added integrity checks of eDisGo object `#231 `_ * added functionality to save to and load from zip archive `#216 `_ * added option to not raise error in case power flow did not converge `#207 `_ * added pyplot `#214 `_ * added functionality to create geopandas dataframes `#224 `_ +* added functionality to resample time series `#269 `_ * added tests * major refactoring of loads and time series diff --git a/edisgo/config/config_grid_expansion_default.cfg b/edisgo/config/config_grid_expansion_default.cfg index 4917c2424..a82e027c9 100644 --- a/edisgo/config/config_grid_expansion_default.cfg +++ b/edisgo/config/config_grid_expansion_default.cfg @@ -13,7 +13,8 @@ # Standard equipment for grid expansion measures. Source: Rehtanz et. al.: "Verteilnetzstudie für das Land Baden-Württemberg", 2017. hv_mv_transformer = 40 MVA mv_lv_transformer = 630 kVA -mv_line = NA2XS2Y 3x1x185 RM/25 +mv_line_10kv = NA2XS2Y 3x1x185 RM/25 +mv_line_20kv = NA2XS2Y 3x1x240 lv_line = NAYY 4x1x150 [grid_expansion_allowed_voltage_deviations] diff --git a/edisgo/edisgo.py b/edisgo/edisgo.py index 78fad1900..cc97ad06f 100755 --- a/edisgo/edisgo.py +++ b/edisgo/edisgo.py @@ -116,7 +116,7 @@ class EDisGo: def __init__(self, **kwargs): # load configuration - self._config = Config(config_path=kwargs.get("config_path", None)) + self._config = Config(config_path=kwargs.get("config_path", "default")) # instantiate topology object and load grid data self.topology = Topology(config=self.config) @@ -2037,6 +2037,52 @@ def check_integrity(self): logging.info("Integrity check finished. Please pay attention to warnings.") + def resample_timeseries(self, method: str = "ffill", freq: str = "15min"): + """ + Resamples all generator, load and storage time series to a desired resolution. + + The following time series are affected by this: + + * :attr:`~.network.timeseries.TimeSeries.generators_active_power` + + * :attr:`~.network.timeseries.TimeSeries.loads_active_power` + + * :attr:`~.network.timeseries.TimeSeries.storage_units_active_power` + + * :attr:`~.network.timeseries.TimeSeries.generators_reactive_power` + + * :attr:`~.network.timeseries.TimeSeries.loads_reactive_power` + + * :attr:`~.network.timeseries.TimeSeries.storage_units_reactive_power` + + Both up- and down-sampling methods are possible. + + Parameters + ---------- + method : str, optional + Method to choose from to fill missing values when resampling. + Possible options are: + + * 'ffill' + Propagate last valid observation forward to next valid + observation. See :pandas:`pandas.DataFrame.ffill`. + * 'bfill' + Use next valid observation to fill gap. See + :pandas:`pandas.DataFrame.bfill`. + * 'interpolate' + Fill NaN values using an interpolation method. See + :pandas:`pandas.DataFrame.interpolate`. + + Default: 'ffill'. + freq : str, optional + Frequency that time series is resampled to. Offset aliases can be found + here: + https://pandas.pydata.org/pandas-docs/stable/user_guide/timeseries.html#offset-aliases. + Default: '15min'. + + """ + self.timeseries.resample_timeseries(method=method, freq=freq) + def import_edisgo_from_pickle(filename, path=""): abs_path = os.path.abspath(path) diff --git a/edisgo/equipment/equipment-parameters_LV_cables.csv b/edisgo/equipment/equipment-parameters_LV_cables.csv index 475b3c58e..ac72e50fc 100644 --- a/edisgo/equipment/equipment-parameters_LV_cables.csv +++ b/edisgo/equipment/equipment-parameters_LV_cables.csv @@ -1,10 +1,10 @@ -name,U_n,I_max_th,R_per_km,L_per_km -#-,kV,kA,ohm/km,mH/km -NAYY 4x1x300,0.4,0.419,0.1,0.279 -NAYY 4x1x240,0.4,0.364,0.125,0.254 -NAYY 4x1x185,0.4,0.313,0.164,0.256 -NAYY 4x1x150,0.4,0.275,0.206,0.256 -NAYY 4x1x120,0.4,0.245,0.253,0.256 -NAYY 4x1x95,0.4,0.215,0.320,0.261 -NAYY 4x1x50,0.4,0.144,0.449,0.270 -NAYY 4x1x35,0.4,0.123,0.868,0.271 +name,U_n,I_max_th,R_per_km,L_per_km,C_per_km +#-,kV,kA,ohm/km,mH/km,uF/km +NAYY 4x1x300,0.4,0.419,0.1,0.279,0 +NAYY 4x1x240,0.4,0.364,0.125,0.254,0 +NAYY 4x1x185,0.4,0.313,0.164,0.256,0 +NAYY 4x1x150,0.4,0.275,0.206,0.256,0 +NAYY 4x1x120,0.4,0.245,0.253,0.256,0 +NAYY 4x1x95,0.4,0.215,0.320,0.261,0 +NAYY 4x1x50,0.4,0.144,0.449,0.270,0 +NAYY 4x1x35,0.4,0.123,0.868,0.271,0 diff --git a/edisgo/flex_opt/charging_strategies.py b/edisgo/flex_opt/charging_strategies.py index c92057004..9946fc227 100644 --- a/edisgo/flex_opt/charging_strategies.py +++ b/edisgo/flex_opt/charging_strategies.py @@ -32,7 +32,7 @@ ], } -logger = logging.getLogger("edisgo") +logger = logging.getLogger(__name__) # TODO: the dummy timeseries should be as long as the simulated days and not diff --git a/edisgo/flex_opt/reinforce_measures.py b/edisgo/flex_opt/reinforce_measures.py index cc9126a87..2fb896d27 100644 --- a/edisgo/flex_opt/reinforce_measures.py +++ b/edisgo/flex_opt/reinforce_measures.py @@ -398,7 +398,7 @@ def reinforce_lines_voltage_issues(edisgo_obj, grid, crit_nodes): ] elif isinstance(grid, MVGrid): standard_line = edisgo_obj.config["grid_expansion_standard_equipment"][ - "mv_line" + f"mv_line_{int(grid.nominal_voltage)}kv" ] else: raise ValueError("Inserted grid is invalid.") @@ -693,40 +693,52 @@ def _replace_by_parallel_standard_lines(lines): lines_changes.update(number_parallel_lines.to_dict()) - standard_line_type = edisgo_obj.config["grid_expansion_standard_equipment"][ - "{}_line".format(voltage_level) - ] - lines_changes = {} # chose lines of right grid level relevant_lines = edisgo_obj.topology.lines_df.loc[ crit_lines[crit_lines.voltage_level == voltage_level].index ] + if not relevant_lines.empty: + nominal_voltage = edisgo_obj.topology.buses_df.loc[ + edisgo_obj.topology.lines_df.loc[relevant_lines.index[0], "bus0"], "v_nom" + ] + if nominal_voltage == 0.4: + standard_line_type = edisgo_obj.config["grid_expansion_standard_equipment"][ + "lv_line" + ] + else: + standard_line_type = edisgo_obj.config["grid_expansion_standard_equipment"][ + f"mv_line_{int(nominal_voltage)}kv" + ] - # handling of standard lines - lines_standard = relevant_lines.loc[relevant_lines.type_info == standard_line_type] - if not lines_standard.empty: - _add_parallel_standard_lines(lines_standard.index) + # handling of standard lines + lines_standard = relevant_lines.loc[ + relevant_lines.type_info == standard_line_type + ] + if not lines_standard.empty: + _add_parallel_standard_lines(lines_standard.index) - # get lines that have not been updated yet (i.e. that are not standard - # lines) - relevant_lines = relevant_lines.loc[ - ~relevant_lines.index.isin(lines_standard.index) - ] - # handling of cables where adding one cable is sufficient - lines_single = ( - relevant_lines.loc[relevant_lines.num_parallel == 1] - .loc[relevant_lines.kind == "cable"] - .loc[crit_lines.max_rel_overload < 2] - ) - if not lines_single.empty: - _add_one_parallel_line_of_same_type(lines_single.index) + # get lines that have not been updated yet (i.e. that are not standard + # lines) + relevant_lines = relevant_lines.loc[ + ~relevant_lines.index.isin(lines_standard.index) + ] + # handling of cables where adding one cable is sufficient + lines_single = ( + relevant_lines.loc[relevant_lines.num_parallel == 1] + .loc[relevant_lines.kind == "cable"] + .loc[crit_lines.max_rel_overload < 2] + ) + if not lines_single.empty: + _add_one_parallel_line_of_same_type(lines_single.index) - # handle rest of lines (replace by as many parallel standard lines as - # needed) - relevant_lines = relevant_lines.loc[~relevant_lines.index.isin(lines_single.index)] - if not relevant_lines.empty: - _replace_by_parallel_standard_lines(relevant_lines.index) + # handle rest of lines (replace by as many parallel standard lines as + # needed) + relevant_lines = relevant_lines.loc[ + ~relevant_lines.index.isin(lines_single.index) + ] + if not relevant_lines.empty: + _replace_by_parallel_standard_lines(relevant_lines.index) return lines_changes diff --git a/edisgo/io/pypsa_io.py b/edisgo/io/pypsa_io.py index 50fd913a4..4f3e2df3a 100755 --- a/edisgo/io/pypsa_io.py +++ b/edisgo/io/pypsa_io.py @@ -106,7 +106,7 @@ def _set_slack(grid): ], "Line": edisgo_object.topology.lines_df.loc[ :, - ["bus0", "bus1", "x", "r", "s_nom", "num_parallel", "length"], + ["bus0", "bus1", "x", "r", "b", "s_nom", "num_parallel", "length"], ], "Transformer": edisgo_object.topology.transformers_df.loc[ :, ["bus0", "bus1", "x_pu", "r_pu", "type_info", "s_nom"] diff --git a/edisgo/network/electromobility.py b/edisgo/network/electromobility.py index 72739aa85..de6190391 100644 --- a/edisgo/network/electromobility.py +++ b/edisgo/network/electromobility.py @@ -12,7 +12,7 @@ if "READTHEDOCS" not in os.environ: import geopandas as gpd -logger = logging.getLogger("edisgo") +logger = logging.getLogger(__name__) COLUMNS = { "charging_processes_df": [ diff --git a/edisgo/network/timeseries.py b/edisgo/network/timeseries.py index 5b5e4abba..8f0194fb6 100644 --- a/edisgo/network/timeseries.py +++ b/edisgo/network/timeseries.py @@ -307,9 +307,11 @@ def reset(self): Resets all time series. Active and reactive power time series of all loads, generators and storage units - are deleted, as well as everything stored in :py:attr:`~time_series_raw`. + are deleted, as well as timeindex everything stored in + :py:attr:`~time_series_raw`. """ + self.timeindex = pd.DatetimeIndex([]) self.generators_active_power = None self.loads_active_power = None self.storage_units_active_power = None @@ -2145,6 +2147,88 @@ def _check_if_components_exist( return set(component_names) - set(comps_not_in_network) return component_names + def resample_timeseries(self, method: str = "ffill", freq: str = "15min"): + """ + Resamples all generator, load and storage time series to a desired resolution. + + See :attr:`~.EDisGo.resample_timeseries` for more information. + + Parameters + ---------- + method : str, optional + See :attr:`~.EDisGo.resample_timeseries` for more information. + + freq : str, optional + See :attr:`~.EDisGo.resample_timeseries` for more information. + + """ + + # add time step at the end of the time series in case of up-sampling so that + # last time interval in the original time series is still included + attrs = self._attributes + freq_orig = self.timeindex[1] - self.timeindex[0] + df_dict = {} + for attr in attrs: + df_dict[attr] = getattr(self, attr) + if pd.Timedelta(freq) < freq_orig: # up-sampling + new_dates = pd.DatetimeIndex([df_dict[attr].index[-1] + freq_orig]) + else: # down-sampling + new_dates = pd.DatetimeIndex([df_dict[attr].index[-1]]) + df_dict[attr] = ( + df_dict[attr] + .reindex(df_dict[attr].index.union(new_dates).unique().sort_values()) + .ffill() + ) + + # create new index + if pd.Timedelta(freq) < freq_orig: # up-sampling + index = pd.date_range( + self.timeindex[0], + self.timeindex[-1] + freq_orig, + freq=freq, + closed="left", + ) + else: # down-sampling + index = pd.date_range( + self.timeindex[0], + self.timeindex[-1], + freq=freq, + ) + + # set new timeindex + self._timeindex = index + + # resample time series + if pd.Timedelta(freq) < freq_orig: # up-sampling + if method == "interpolate": + for attr in attrs: + setattr( + self, + attr, + df_dict[attr].resample(freq, closed="left").interpolate(), + ) + elif method == "ffill": + for attr in attrs: + setattr( + self, attr, df_dict[attr].resample(freq, closed="left").ffill() + ) + elif method == "bfill": + for attr in attrs: + setattr( + self, attr, df_dict[attr].resample(freq, closed="left").bfill() + ) + else: + raise NotImplementedError( + f"Resampling method {method} is not implemented." + ) + else: # down-sampling + for attr in attrs: + setattr( + self, + attr, + df_dict[attr].resample(freq).mean(), + ) + class TimeSeriesRaw: """ diff --git a/edisgo/network/topology.py b/edisgo/network/topology.py index 1eeeddbdd..78cf9e84f 100755 --- a/edisgo/network/topology.py +++ b/edisgo/network/topology.py @@ -20,6 +20,7 @@ calculate_apparent_power, calculate_line_reactance, calculate_line_resistance, + calculate_line_susceptance, select_cable, ) @@ -48,6 +49,7 @@ "length", "x", "r", + "b", "s_nom", "num_parallel", "type_info", @@ -1247,7 +1249,7 @@ def add_line(self, bus0, bus1, length, **kwargs): Adds line to topology. Line name is generated automatically. - If `type_info` is provided, `x`, `r` and `s_nom` are calculated. + If `type_info` is provided, `x`, `r`, `b` and `s_nom` are calculated. Parameters ---------- @@ -1262,8 +1264,8 @@ def add_line(self, bus0, bus1, length, **kwargs): ------------------ kwargs : Kwargs may contain any further attributes in :py:attr:`~lines_df`. - It is necessary to either provide `type_info` to determine `x`, `r` - and `s_nom` of the line, or to provide `x`, `r` and `s_nom` + It is necessary to either provide `type_info` to determine `x`, `r`, `b` + and `s_nom` of the line, or to provide `x`, `r`, `b` and `s_nom` directly. """ @@ -1328,6 +1330,7 @@ def _get_line_data(): # unpack optional parameters x = kwargs.get("x", None) r = kwargs.get("r", None) + b = kwargs.get("b", 0.0) s_nom = kwargs.get("s_nom", None) num_parallel = kwargs.get("num_parallel", 1) type_info = kwargs.get("type_info", None) @@ -1335,10 +1338,10 @@ def _get_line_data(): # if type of line is specified calculate x, r and s_nom if type_info is not None: - if x is not None or r is not None or s_nom is not None: + if x is not None or r is not None or b is not None or s_nom is not None: warnings.warn( "When line 'type_info' is provided when creating a new " - "line, x, r and s_nom are calculated and provided " + "line, x, r, b and s_nom are calculated and provided " "parameters are overwritten." ) line_data = _get_line_data() @@ -1348,6 +1351,7 @@ def _get_line_data(): ).iloc[0, :] x = calculate_line_reactance(line_data.L_per_km, length, num_parallel) r = calculate_line_resistance(line_data.R_per_km, length, num_parallel) + b = calculate_line_susceptance(line_data.C_per_km, length, num_parallel) s_nom = calculate_apparent_power( line_data.U_n, line_data.I_max_th, num_parallel ) @@ -1374,6 +1378,7 @@ def _get_line_data(): "bus1": bus1, "x": x, "r": r, + "b": b, "length": length, "type_info": type_info, "num_parallel": num_parallel, @@ -1595,7 +1600,7 @@ def update_number_of_parallel_lines(self, lines_num_parallel): """ Changes number of parallel lines and updates line attributes. - When number of parallel lines changes, attributes x, r, and s_nom have + When number of parallel lines changes, attributes x, r, b, and s_nom have to be adapted, which is done in this function. Parameters @@ -1606,12 +1611,17 @@ def update_number_of_parallel_lines(self, lines_num_parallel): new number of parallel lines. """ - # update x, r and s_nom + # update x, r, b and s_nom self._lines_df.loc[lines_num_parallel.index, "x"] = ( self._lines_df.loc[lines_num_parallel.index, "x"] * self._lines_df.loc[lines_num_parallel.index, "num_parallel"] / lines_num_parallel ) + self._lines_df.loc[lines_num_parallel.index, "b"] = ( + self._lines_df.loc[lines_num_parallel.index, "b"] + / self._lines_df.loc[lines_num_parallel.index, "num_parallel"] + * lines_num_parallel + ) self._lines_df.loc[lines_num_parallel.index, "r"] = ( self._lines_df.loc[lines_num_parallel.index, "r"] * self._lines_df.loc[lines_num_parallel.index, "num_parallel"] @@ -1652,7 +1662,9 @@ def change_line_type(self, lines, new_line_type): data_new_line = self.equipment_data["lv_cables"].loc[new_line_type] except KeyError: try: - data_new_line = self.equipment_data["mv_cables"].loc[new_line_type] + data_new_line = ( + self.equipment_data["mv_cables"].loc[new_line_type].copy() + ) # in case of MV cable adapt nominal voltage to MV voltage grid_voltage = self.buses_df.at[ self.lines_df.at[lines[0], "bus0"], "v_nom" @@ -1679,19 +1691,25 @@ def change_line_type(self, lines, new_line_type): self._lines_df.loc[lines, "num_parallel"] = 1 self._lines_df.loc[lines, "kind"] = "cable" - self._lines_df.loc[lines, "r"] = ( - data_new_line.R_per_km * self.lines_df.loc[lines, "length"] + self._lines_df.loc[lines, "r"] = calculate_line_resistance( + data_new_line.R_per_km, + self.lines_df.loc[lines, "length"], + self._lines_df.loc[lines, "num_parallel"], + ) + self._lines_df.loc[lines, "x"] = calculate_line_reactance( + data_new_line.L_per_km, + self.lines_df.loc[lines, "length"], + self._lines_df.loc[lines, "num_parallel"], ) - self._lines_df.loc[lines, "x"] = ( - data_new_line.L_per_km - * 2 - * np.pi - * 50 - / 1e3 - * self.lines_df.loc[lines, "length"] + self._lines_df.loc[lines, "b"] = calculate_line_susceptance( + data_new_line.C_per_km, + self.lines_df.loc[lines, "length"], + self._lines_df.loc[lines, "num_parallel"], ) - self._lines_df.loc[lines, "s_nom"] = ( - np.sqrt(3) * data_new_line.U_n * data_new_line.I_max_th + self._lines_df.loc[lines, "s_nom"] = calculate_apparent_power( + data_new_line.U_n, + data_new_line.I_max_th, + self._lines_df.loc[lines, "num_parallel"], ) def connect_to_mv(self, edisgo_object, comp_data, comp_type="generator"): diff --git a/edisgo/tools/config.py b/edisgo/tools/config.py index 55c011781..c97bba4f5 100644 --- a/edisgo/tools/config.py +++ b/edisgo/tools/config.py @@ -53,31 +53,38 @@ class Config: Parameters ----------- - config_path : None or :obj:`str` or :obj:`dict` + config_path : None or str or :dict Path to the config directory. Options are: + * 'default' (default) + If `config_path` is set to 'default', the provided default config files + are used directly. + * str + If `config_path` is a string, configs will be loaded from the + directory specified by `config_path`. If the directory + does not exist, it is created. If config files don't exist, the + default config files are copied into the directory. + * dict + A dictionary can be used to specify different paths to the + different config files. The dictionary must have the following + keys: + + * 'config_db_tables' + + * 'config_grid' + + * 'config_grid_expansion' + + * 'config_timeseries' + + Values of the dictionary are paths to the corresponding + config file. In contrast to the other options, the directories + and config files must exist and are not automatically created. * None - If `config_path` is None configs are loaded from the edisgo - default config directory ($HOME$/.edisgo). If the directory - does not exist it is created. If config files don't exist the - default config files are copied into the directory. - * :obj:`str` - If `config_path` is a string configs will be loaded from the - directory specified by `config_path`. If the directory - does not exist it is created. If config files don't exist the - default config files are copied into the directory. - * :obj:`dict` - A dictionary can be used to specify different paths to the - different config files. The dictionary must have the following - keys: - * 'config_db_tables' - * 'config_grid' - * 'config_grid_expansion' - * 'config_timeseries' - - Values of the dictionary are paths to the corresponding - config file. In contrast to the other two options the directories - and config files must exist and are not automatically created. + If `config_path` is None, configs are loaded from the edisgo + default config directory ($HOME$/.edisgo). If the directory + does not exist, it is created. If config files don't exist, the + default config files are copied into the directory. Default: None. @@ -109,7 +116,7 @@ def _load_config(config_path=None): Parameters ----------- - config_path : None or :obj:`str` or dict + config_path : None or str or dict See class definition for more information. Returns @@ -128,7 +135,14 @@ def _load_config(config_path=None): ] # load configs - if isinstance(config_path, dict): + if config_path == "default": + for conf in config_files: + conf = conf + "_default" + load_config( + filename="{}.cfg".format(conf), + config_dir=os.path.join(package_path, "config"), + ) + elif isinstance(config_path, dict): for conf in config_files: load_config( filename="{}.cfg".format(conf), @@ -202,13 +216,13 @@ def load_config(filename, config_dir=None, copy_default_config=True): Parameters ----------- - filename : :obj:`str` + filename : str Config file name, e.g. 'config_grid.cfg'. - config_dir : :obj:`str`, optional + config_dir : str, optional Path to config file. If None uses default edisgo config directory specified in config file 'config_system.cfg' in section 'user_dirs' by subsections 'root_dir' and 'config_dir'. Default: None. - copy_default_config : Boolean + copy_default_config : bool If True copies a default config file into `config_dir` if the specified config file does not exist. Default: True. @@ -254,12 +268,12 @@ def get(section, key): Parameters ----------- - section : :obj:`str` - key : :obj:`str` + section : str + key : str Returns -------- - float or int or Boolean or str + float or int or bool or str The value which will be casted to float, int or boolean. If no cast is successful, the raw string is returned. @@ -285,7 +299,7 @@ def get_default_config_path(): Returns -------- - :obj:`str` + str Path to default edisgo config directory specified in config file 'config_system.cfg' in section 'user_dirs' by subsections 'root_dir' and 'config_dir'. @@ -335,7 +349,7 @@ def make_directory(directory): Parameters ----------- - directory : :obj:`str` + directory : str Directory path """ diff --git a/edisgo/tools/edisgo_run.py b/edisgo/tools/edisgo_run.py deleted file mode 100755 index 77e1f8aa9..000000000 --- a/edisgo/tools/edisgo_run.py +++ /dev/null @@ -1,612 +0,0 @@ -import argparse -import glob -import logging -import multiprocessing as mp -import os -import sys - -import multiprocess as mp2 -import pandas as pd - -from edisgo import EDisGo -from edisgo.flex_opt.exceptions import MaximumIterationError -from edisgo.network.results import Results - - -def setup_logging( - logfilename=None, - logfile_loglevel="debug", - console_loglevel="info", - **logging_kwargs -): - # a dict to help with log level definition - loglevel_dict = { - "info": logging.INFO, - "debug": logging.DEBUG, - "warn": logging.WARNING, - "warning": logging.WARNING, - "error": logging.ERROR, - "critical": logging.CRITICAL, - } - - if not (logfilename): - logfilename = "edisgo_run.log" - - logging.basicConfig( - filename=logfilename, - format="%(asctime)s - %(name)s -" + " %(levelname)s - %(message)s", - datefmt="%m/%d/%Y %H:%M:%S", - level=loglevel_dict[logfile_loglevel], - ) - - root_logger = logging.getLogger() - - console_stream = logging.StreamHandler() - console_stream.setLevel(loglevel_dict[console_loglevel]) - console_formatter = logging.Formatter( - fmt="%(asctime)s - %(name)s -" + " %(levelname)s - %(message)s", - datefmt="%m/%d/%Y %H:%M:%S", - ) - console_stream.setFormatter(console_formatter) - - # add stream handler to root logger - root_logger.addHandler(console_stream) - - return root_logger - - -def run_edisgo_basic( - ding0_path, generator_scenario=None, analysis="worst-case", *edisgo_grid -): - """ - Determine network expansion costs for given ding0 grid and scenario. - - Parameters - ---------- - ding0_path : str - Path to ding0 network csv data. - - analysis : str - Either 'worst-case' or 'timeseries'. - - generator_scenario : None or :obj:`str` - If provided defines which scenario of future generator park to use - and invokes import of these generators. Possible options are 'nep2035' - and 'ego100'. - - edisgo_grid : :class:`~.EDisGo` (optional) - If an EDisGo object is provided it is used instead of creating a new - object using parameters `ding0_path` and `analysis`. - - Returns - ------- - edisgo_grid : :class:`~.EDisGo` - costs : :pandas:`pandas.DataFrame` - Costs of network expansion - grid_issues : dict - Log for remaining grid issues after network expansion. For grids - resulting in an error this gives the error message. - - """ - - grid_issues = {} - - if edisgo_grid: # if an edisgo_grid is passed in arg then ignore everything else - edisgo_grid = edisgo_grid[0] - else: - try: - if "worst-case" in analysis: - edisgo_grid = EDisGo( - ding0_grid=ding0_path, worst_case_analysis=analysis - ) - elif "timeseries" in analysis: - edisgo_grid = EDisGo( - ding0_grid=ding0_path, - timeseries_generation_fluctuating="oedb", - timeseries_load="demandlib", - ) - except FileNotFoundError as e: - return ( - None, - pd.DataFrame(), - {"network": edisgo_grid, "msg": str(e)}, - ) - - logging.info("Grid expansion for MV network {}".format(edisgo_grid.topology.id)) - - # Import generators - if generator_scenario: - logging.info("Grid expansion for scenario '{}'.".format(generator_scenario)) - edisgo_grid.import_generators(generator_scenario=generator_scenario) - else: - logging.info("Grid expansion with status quo generator capacities.") - - try: - # Do network reinforcement - edisgo_grid.reinforce() - - # Get costs - costs_grouped = edisgo_grid.network.results.grid_expansion_costs.groupby( - ["type"] - ).sum() - costs = pd.DataFrame( - costs_grouped.values, - columns=costs_grouped.columns, - index=[ - [edisgo_grid.network.id] * len(costs_grouped), - costs_grouped.index, - ], - ).reset_index() - costs.rename(columns={"level_0": "network"}, inplace=True) - - grid_issues["network"] = None - grid_issues["msg"] = None - - logging.info("SUCCESS!") - except MaximumIterationError: - grid_issues["network"] = edisgo_grid.network.id - grid_issues["msg"] = str(edisgo_grid.network.results.unresolved_issues) - costs = pd.DataFrame(dtype=float) - logging.warning("Unresolved issues left after network expansion.") - except Exception as e: - grid_issues["network"] = edisgo_grid.network.id - grid_issues["msg"] = repr(e) - costs = pd.DataFrame(dtype=float) - logging.exception() - - return edisgo_grid, costs, grid_issues - - -def run_edisgo_twice(run_args): - """ - Run network analysis twice on same network: once w/ and once w/o new generators - - ToDo: adapt to refactored code! - - First run without connection of new generators approves sufficient network - hosting capacity. Otherwise, network is reinforced. - Second run assessment network extension needs in terms of RES integration - - Parameters - ---------- - run_args : list - Optional parameters for :func:`run_edisgo_basic`. - - Returns - ------- - all_costs_before_geno_import : :pandas:`pandas.Dataframe` - Grid extension cost before network connection of new generators - all_grid_issues_before_geno_import : dict - Remaining overloading or over-voltage issues in network - all_costs : :pandas:`pandas.Dataframe` - Grid extension cost due to network connection of new generators - all_grid_issues : dict - Remaining overloading or over-voltage issues in network - """ - - # base case with no generator import - ( - edisgo_grid, - costs_before_geno_import, - grid_issues_before_geno_import, - ) = run_edisgo_basic(*run_args) - - if edisgo_grid: - # clear the results object - edisgo_grid.results = Results(edisgo_grid) - edisgo_grid.config = None - - # case after generator import - # run_args = [ding0_filename] - # run_args.extend(run_args_opt) - run_args.append(edisgo_grid) - - _, costs, grid_issues = run_edisgo_basic(*run_args) - - return ( - costs_before_geno_import, - grid_issues_before_geno_import, - costs, - grid_issues, - ) - else: - return ( - costs_before_geno_import, - grid_issues_before_geno_import, - costs_before_geno_import, - grid_issues_before_geno_import, - ) - - -def run_edisgo_pool( - ding0_file_list, - run_args_opt=[None, "worst-case"], - workers=mp.cpu_count(), - worker_lifetime=1, -): - """ - Use python multiprocessing toolbox for parallelization - - Several grids are analyzed in parallel. - - Parameters - ---------- - ding0_file_list : list - Ding0 network data file names - run_args_opt : list - eDisGo options, see :func:`run_edisgo_basic` and - :func:`run_edisgo_twice`, has to contain generator_scenario and analysis as - entries - workers: int - Number of parallel process - worker_lifetime : int - Bunch of grids sequentially analyzed by a worker - - Returns - ------- - all_costs_before_geno_import : list - Grid extension cost before network connection of new generators - all_grid_issues_before_geno_import : list - Remaining overloading or over-voltage issues in network - all_costs : list - Grid extension cost due to network connection of new generators - all_grid_issues : list - Remaining overloading or over-voltage issues in network - """ - - def collect_pool_results(result): - results.append(result) - - results = [] - - pool = mp.Pool(workers, maxtasksperchild=worker_lifetime) - - for file in ding0_file_list: - edisgo_args = [file] + run_args_opt - pool.apply_async( - func=run_edisgo_twice, - args=(edisgo_args,), - callback=collect_pool_results, - ) - - pool.close() - pool.join() - - # process results data - all_costs_before_geno_import = [r[0] for r in results] - all_grid_issues_before_geno_import = [r[1] for r in results] - all_costs = [r[2] for r in results] - all_grid_issues = [r[3] for r in results] - - return ( - all_costs_before_geno_import, - all_grid_issues_before_geno_import, - all_costs, - all_grid_issues, - ) - - -def run_edisgo_pool_flexible( - ding0_id_list, - func, - func_arguments, - workers=mp2.cpu_count(), - worker_lifetime=1, -): - """ - Use python multiprocessing toolbox for parallelization - - Several grids are analyzed in parallel based on your custom function that - defines the specific application of eDisGo. - - Parameters - ---------- - ding0_id_list : list of int - List of ding0 network data IDs (also known as HV/MV substation IDs) - func : any function - Your custom function that shall be parallelized - func_arguments : tuple - Arguments to custom function ``func`` - workers: int - Number of parallel process - worker_lifetime : int - Bunch of grids sequentially analyzed by a worker - - Notes - ----- - Please note, the following requirements for the custom function which is to - be executed in parallel - - #. It must return an instance of the type :class:`~.edisgo.EDisGo`. - #. The first positional argument is the MV network district id (as int). It is - prepended to the tuple of arguments ``func_arguments`` - - - Returns - ------- - containers : dict of :class:`~.edisgo.EDisGo` - Dict of EDisGo instances keyed by its ID - """ - - def collect_pool_results(result): - """ - Store results from parallelized calculation in structured manner - - Parameters - ---------- - result: :class:`~.edisgo.EDisGo` - """ - results.update({result.network.id: result}) - - results = {} - - pool = mp2.Pool(workers, maxtasksperchild=worker_lifetime) - - def error_callback(key): - return lambda o: results.update({key: o}) - - for ding0_id in ding0_id_list: - edisgo_args = (ding0_id, *func_arguments) - pool.apply_async( - func=func, - args=edisgo_args, - callback=collect_pool_results, - error_callback=error_callback(ding0_id), - ) - - pool.close() - pool.join() - - return results - - -def edisgo_run(): - # create the argument parser - example_text = """Examples - - ...assumes all files located in PWD. - - Analyze a single network in 'worst-case' - - edisgo_run -f ding0_grids__997.pkl -wc - - - Analyze multiple grids in 'worst-case' using parallelization. Grid IDs are - specified by the grids_list.txt. - - edisgo_run -ds '' grids_list.txt ding0_grids__{}.pkl -wc --parallel - """ - parser = argparse.ArgumentParser( - description="Commandline running" + "of eDisGo", - epilog=example_text, - formatter_class=argparse.RawDescriptionHelpFormatter, - ) - - # add the verbosity arguments - - ding0_files_parsegroup = parser.add_mutually_exclusive_group(required=True) - - ding0_files_parsegroup.add_argument( - "-f", - "--ding0-file-path", - type=str, - action="store", - dest="ding0_filename", - help="Path to a single ding0 file.", - ) - ding0_files_parsegroup.add_argument( - "-d", - "--ding0-files-directory", - type=str, - action="store", - dest="ding0_dirglob", - help="Path to a directory of ding0 files " - + "along with a file name pattern for glob input.", - ) - ding0_files_parsegroup.add_argument( - "-ds", - "--ding0-files-directory-selection", - type=str, - nargs=3, - action="store", - dest="ding0_dir_select", - help="Path to a directory of ding0 files, " - + "Path to file with list of network district numbers " - + "(one number per line), " - + "and file name template using {} where number " - + "is to be inserted . Convention is to use " - + "a double underscore before network district number " - + " like so '__{}'.", - ) - - analysis_parsegroup = parser.add_mutually_exclusive_group() - - analysis_parsegroup.add_argument( - "-wc", - "--worst-case", - action="store_true", - help="Performs a worst-case simulation with " + "a single snapshot", - ) - - analysis_parsegroup.add_argument( - "-ts", - "--timeseries", - action="store_true", - help="Performs a worst-case simulation with " + "a time-series", - ) - - parser.add_argument( - "-s", - "--scenario", - type=str, - default=None, - choices=[None, "nep2035", "ego100"], - help="'None' or 'string'\n" - + "If provided defines which scenario " - + "of future generator park to use " - + "and invokes import of these generators.\n" - + "Possible options are 'nep2035'and 'ego100'.", - ) - - parser.add_argument( - "-o", - "--output-dir", - nargs="?", - metavar="/path/to/output/", - dest="out_dir", - type=str, - default=os.path.join(sys.path[0]), - help="Absolute path to results data location.", - ) - - parser.add_argument( - "-p", - "--parallel", - action="store_true", - help="Parallel execution of multiple " - "grids. Parallelization is provided " - "by multiprocessing.", - ) - - parser.add_argument( - "-w", - "--workers", - nargs="?", - metavar="1..inf", - dest="workers", - type=int, - default=mp.cpu_count(), - help="Number of workers in parallel. In other words, " - "cores that are used for parallelization.", - ) - - parser.add_argument( - "-lw", - "--lifetime-workers", - nargs="?", - metavar="1..inf", - dest="worker_lifetime", - type=int, - default=None, - help="Lifetime of a worker of the cluster doing the " - "work. The lifetime is given is number of jobs a" - " worker does before it is replaced by a freshly " - "new one." - "The default sets the lifetime to the pools " - "lifetime. This can cause memory issues!", - ) - - args = parser.parse_args(sys.argv[1:]) - - # get current time for output file names - exec_time = pd.datetime.now().strftime("%Y-%m-%d_%H%M") - - logger = setup_logging( # noqa: F841 - logfilename="test.log", - logfile_loglevel="debug", - console_loglevel="info", - ) - - # get the list of files to run on - if args.ding0_filename: - ding0_file_list = [args.ding0_filename] - - elif args.ding0_dirglob: - ding0_file_list = glob.glob(args.ding0_dirglob) - - elif args.ding0_dir_select: - with open(args.ding0_dir_select[1], "r") as file_handle: - ding0_file_list_grid_district_numbers = list(file_handle) - ding0_file_list_grid_district_numbers = [ - _.splitlines()[0] for _ in ding0_file_list_grid_district_numbers - ] - - ding0_file_list = map( - lambda x: args.ding0_dir_select[0] + args.ding0_dir_select[2].format(x), - ding0_file_list_grid_district_numbers, - ) - else: - raise FileNotFoundError("Some of the Arguments for input files are missing.") - - # this is the serial version of the run system - run_func = run_edisgo_basic # noqa: F841 - - run_args_opt_no_scenario = [None] - run_args_opt = [args.scenario] - if args.worst_case: - run_args_opt_no_scenario.append("worst-case") - run_args_opt.append("worst-case") - elif args.timeseries: - run_args_opt_no_scenario.append("timeseries") - run_args_opt.append("timeseries") - - all_costs_before_geno_import = [] - all_grid_issues_before_geno_import = {"network": [], "msg": []} - all_costs = [] - all_grid_issues = {"network": [], "msg": []} - - if not args.parallel: - for ding0_filename in ding0_file_list: - grid_district = _get_griddistrict(ding0_filename) # noqa: F821, F841 - - run_args = [ding0_filename] - run_args.extend(run_args_opt_no_scenario) - - ( - costs_before_geno_import, - grid_issues_before_geno_import, - costs, - grid_issues, - ) = run_edisgo_twice(run_args) - - all_costs_before_geno_import.append(costs_before_geno_import) - all_grid_issues_before_geno_import["network"].append( - grid_issues_before_geno_import["network"] - ) - all_grid_issues_before_geno_import["msg"].append( - grid_issues_before_geno_import["msg"] - ) - all_costs.append(costs) - all_grid_issues["network"].append(grid_issues["network"]) - all_grid_issues["msg"].append(grid_issues["msg"]) - else: - ( - all_costs_before_geno_import, - all_grid_issues_before_geno_import, - all_costs, - all_grid_issues, - ) = run_edisgo_pool( - ding0_file_list, - run_args_opt_no_scenario, - args.workers, - args.worker_lifetime, - ) - - # consolidate costs for all the networks - all_costs_before_geno_import = pd.concat( - all_costs_before_geno_import, ignore_index=True - ) - all_costs = pd.concat(all_costs, ignore_index=True) - - # write costs and error messages to csv files - pd.DataFrame(all_grid_issues_before_geno_import).dropna(axis=0, how="all").to_csv( - args.out_dir + exec_time + "_" + "grid_issues_before_geno_import.csv", - index=False, - ) - - with open( - args.out_dir + exec_time + "_" + "costs_before_geno_import.csv", "a" - ) as f: - f.write(",,,# units: length in km,, total_costs in kEUR\n") - all_costs_before_geno_import.to_csv(f, index=False) - - pd.DataFrame(all_grid_issues).dropna(axis=0, how="all").to_csv( - args.out_dir + exec_time + "_" + "grid_issues.csv", index=False - ) - with open(args.out_dir + exec_time + "_" + "costs.csv", "a") as f: - f.write(",,,# units: length in km,, total_costs in kEUR\n") - all_costs.to_csv(f, index=False) - - -if __name__ == "__main__": - pass diff --git a/edisgo/tools/logger.py b/edisgo/tools/logger.py new file mode 100644 index 000000000..93381c9c3 --- /dev/null +++ b/edisgo/tools/logger.py @@ -0,0 +1,205 @@ +import logging +import os +import sys + +from datetime import datetime + +from edisgo.tools import config as cfg_edisgo + + +def setup_logger( + file_name=None, + log_dir=None, + loggers=None, + stream_output=sys.stdout, + debug_message=False, + reset_loggers=False, +): + """ + Setup different loggers with individual logging levels and where to write output. + + The following table from python 'Logging Howto' shows you when which logging level + is used. + + .. tabularcolumns:: |l|L| + + +--------------+---------------------------------------------+ + | Level | When it's used | + +==============+=============================================+ + | ``DEBUG`` | Detailed information, typically of interest | + | | only when diagnosing problems. | + +--------------+---------------------------------------------+ + | ``INFO`` | Confirmation that things are working as | + | | expected. | + +--------------+---------------------------------------------+ + | ``WARNING`` | An indication that something unexpected | + | | happened, or indicative of some problem in | + | | the near future (e.g. 'disk space low'). | + | | The software is still working as expected. | + +--------------+---------------------------------------------+ + | ``ERROR`` | Due to a more serious problem, the software | + | | has not been able to perform some function. | + +--------------+---------------------------------------------+ + | ``CRITICAL`` | A serious error, indicating that the program| + | | itself may be unable to continue running. | + +--------------+---------------------------------------------+ + + Parameters + ---------- + file_name : str or None + Specifies file name of file logging information is written to. Possible options + are: + + * None (default) + Saves log file with standard name `%Y_%m_%d-%H:%M:%S_edisgo.log`. + * str + Saves log file with the specified file name. + + log_dir : str or None + Specifies directory log file is saved to. Possible options are: + + * None (default) + Saves log file in current working directory. + * "default" + Saves log file into directory configured in the configs. + * str + Saves log file into the specified directory. + + loggers : None or list(dict) + + * None + Configuration as shown in the example below is used. Configures root logger + with file and stream level warning and the edisgo logger with file and + stream level debug. + * list(dict) + List of dicts with the logger configuration. Each dictionary must contain + the following keys and corresponding values: + + * 'name' + Specifies name of the logger as string, e.g. 'root' or 'edisgo'. + * 'file_level' + Specifies file logging level. Possible options are: + + * "debug" + Logs logging messages with logging level logging.DEBUG and above. + * "info" + Logs logging messages with logging level logging.INFO and above. + * "warning" + Logs logging messages with logging level logging.WARNING and above. + * "error" + Logs logging messages with logging level logging.ERROR and above. + * "critical" + Logs logging messages with logging level logging.CRITICAL. + * None + No logging messages are logged. + * 'stream_level' + Specifies stream logging level. Possible options are the same as for + `file_level`. + + stream_output : stream + Default sys.stdout is used. sys.stderr is also possible. + + debug_message : bool + If True the handlers of every configured logger is printed. + + reset_loggers : bool + If True the handlers of all loggers are cleared before configuring the loggers. + + Examples + -------- + >>> setup_logger( + >>> loggers=[ + >>> {"name": "root", "file_level": "warning", "stream_level": "warning"}, + >>> {"name": "edisgo", "file_level": "info", "stream_level": "info"} + >>> ] + >>> ) + + """ + + def create_dir(dir_path): + if not os.path.isdir(dir_path): + os.mkdir(dir_path) + + def get_default_root_dir(): + dir_path = str(cfg_edisgo.get("user_dirs", "root_dir")) + return os.path.join(os.path.expanduser("~"), dir_path) + + def create_home_dir(): + dir_path = get_default_root_dir() + create_dir(dir_path) + + cfg_edisgo.load_config("config_system.cfg") + + if file_name is None: + now = datetime.now() + file_name = now.strftime("%Y_%m_%d-%H:%M:%S_edisgo.log") + + if log_dir == "default": + create_home_dir() + log_dir = os.path.join( + get_default_root_dir(), cfg_edisgo.get("user_dirs", "log_dir") + ) + create_dir(log_dir) + + if log_dir is not None: + file_name = os.path.join(log_dir, file_name) + + if reset_loggers: + existing_loggers = [logging.getLogger()] # get the root logger + existing_loggers = existing_loggers + [ + logging.getLogger(name) for name in logging.root.manager.loggerDict + ] + + for logger in existing_loggers: + logger.handlers.clear() + + loglevel_dict = { + "debug": logging.DEBUG, + "info": logging.INFO, + "warning": logging.WARNING, + "error": logging.ERROR, + "critical": logging.CRITICAL, + None: logging.CRITICAL + 1, + } + + file_formatter = logging.Formatter( + "%(asctime)s - %(name)s - %(levelname)s: %(message)s" + ) + stream_formatter = logging.Formatter("%(name)s - %(levelname)s: %(message)s") + + if loggers is None: + loggers = [ + {"name": "root", "file_level": "warning", "stream_level": "warning"}, + {"name": "edisgo", "file_level": "info", "stream_level": "info"}, + ] + + for logger_config in loggers: + logger_name = logger_config["name"] + logger_file_level = loglevel_dict[logger_config["file_level"]] + logger_stream_level = loglevel_dict[logger_config["stream_level"]] + + if logger_name == "root": + logger = logging.getLogger() + else: + logger = logging.getLogger(logger_name) + logger.propagate = False + + if logger_file_level < logger_stream_level: + logger.setLevel(logger_file_level) + else: + logger.setLevel(logger_stream_level) + + if logger_file_level < logging.CRITICAL + 1: + file_handler = logging.FileHandler(file_name) + file_handler.setLevel(logger_file_level) + file_handler.setFormatter(file_formatter) + logger.addHandler(file_handler) + + if logger_stream_level < logging.CRITICAL + 1: + console_handler = logging.StreamHandler(stream=stream_output) + console_handler.setLevel(logger_stream_level) + console_handler.setFormatter(stream_formatter) + logger.addHandler(console_handler) + + if debug_message: + print(f"Handlers of logger {logger_name}: {logger.handlers}") diff --git a/edisgo/tools/plots.py b/edisgo/tools/plots.py index 7fcb0fce4..cf2b5f36d 100644 --- a/edisgo/tools/plots.py +++ b/edisgo/tools/plots.py @@ -20,6 +20,7 @@ from pypsa import Network as PyPSANetwork from edisgo.tools import session_scope, tools +from edisgo.tools.pseudo_coordinates import make_pseudo_coordinates_graph if TYPE_CHECKING: from numbers import Number @@ -865,8 +866,8 @@ def color_map_color( value: Number, vmin: Number, vmax: Number, - cmap_name: str = "coolwarm", -): + cmap_name: str | list = "coolwarm", +) -> str: """ Get matching color for a value on a matplotlib color map. @@ -878,8 +879,8 @@ def color_map_color( Minimum value on color map vmax : float or int Maximum value on color map - cmap_name : str - Name of color map to use + cmap_name : str or list + Name of color map to use, or the colormap Returns ------- @@ -888,54 +889,98 @@ def color_map_color( """ norm = matplotlib.colors.Normalize(vmin=vmin, vmax=vmax) - cmap = cm.get_cmap(cmap_name) + if isinstance(cmap_name, str): + cmap = cm.get_cmap(cmap_name) + else: + cmap = matplotlib.colors.LinearSegmentedColormap.from_list("mycmap", cmap_name) rgb = cmap(norm(abs(value)))[:3] color = matplotlib.colors.rgb2hex(rgb) return color -def draw_plotly( +def plot_plotly( edisgo_obj: EDisGo, - G: Graph | None = None, - line_color: str = "relative_loading", - node_color: str = "voltage_deviation", - grid: bool | Grid = False, + grid: Grid | None = None, + line_color: None | str = "relative_loading", + node_color: None | str = "voltage_deviation", + line_result_selection: str = "max", + node_result_selection: str = "max", + selected_timesteps: pd.Timestamp | list | None = None, + center_coordinates: bool = False, + pseudo_coordinates: bool = False, + node_selection: list | bool = False, ) -> BaseFigure: """ - Draw a plotly html figure + Draws a plotly html figure. Parameters ---------- edisgo_obj : :class:`~.EDisGo` - G : :networkx:`networkx.Graph`, optional - Graph representation of the grid as networkx Ordered Graph, where lines are - represented by edges in the graph, and buses and transformers are represented by - nodes. If no graph is given the mv grid graph of the edisgo object is used. - line_color : str - Defines whereby to choose line colors (and implicitly size). Possible - options are: + Selected edisgo_obj to get plotting information from. + + grid : :class:`~.network.grids.Grid` + Grid to plot. If None, the MVGrid of the edisgo_obj is plotted. Default: None. + + line_color : str or None + Defines whereby to choose line colors. Possible options are: * 'loading' - Line color is set according to loading of the line. - * 'relative_loading' (Default) - Line color is set according to relative loading of the line. + Line color is set according to loading of the line. + * 'relative_loading' (default) + Line color is set according to relative loading of the line. * 'reinforce' - Line color is set according to investment costs of the line. + Line color is set according to investment costs of the line. + * None + Line color is black. This is also the fallback, in case other options fail. node_color : str or None - Defines whereby to choose node colors (and implicitly size). Possible - options are: + Defines whereby to choose node colors. Possible options are: * 'adjacencies' - Node color as well as size is set according to the number of direct neighbors. + Node color as well as size is set according to the number of direct + neighbors. * 'voltage_deviation' (default) - Node color is set according to voltage deviation from 1 p.u.. + Node color is set according to voltage deviation from 1 p.u.. + * None + Line color is black. This is also the fallback, in case other options fail. + + line_result_selection : str + Defines which values are shown for the load of the lines: + + * 'min' + Minimal line load of all time steps. + * 'max' (default) + Maximal line load of all time steps. - grid : :class:`~.network.grids.Grid` or bool - Grid to use as root node. If a grid is given the transforer station is used - as root. If False the root is set to the coordinates x=0 and y=0. Else the - coordinates from the hv-mv-station of the mv grid are used. Default: False + node_result_selection : str + Defines which values are shown for the voltage of the nodes: + + * 'min' + Minimal node voltage of all time steps. + * 'max' (default) + Maximal node voltage of all time steps. + + selected_timesteps : :pandas:`pandas.Timestamp` or \ + list(:pandas:`pandas.Timestamp`) or None + Selected time steps to show results for. + + * None (default) + All time steps are used. + * list(:pandas:`pandas.Timestamp`) or \ + :pandas:`pandas.Timestamp` + Selected time steps are used. + + center_coordinates : bool + Enables the centering of the coordinates. If True the transformer node is set + to the coordinates x=0 and y=0. Else, the coordinates from the HV/MV-station + of the MV grid are used. Default: False. + + pseudo_coordinates : bool + Enable pseudo coordinates for the plotted grid. Default: False. + + node_selection : bool or list(str) + Only plot selected nodes. Default: False. Returns ------- @@ -943,259 +988,439 @@ def draw_plotly( Plotly figure with branches and nodes. """ - # initialization - transformer_4326_to_3035 = Transformer.from_crs( - "EPSG:4326", - "EPSG:3035", - always_xy=True, - ) + if grid is None: + grid = edisgo_obj.topology.mv_grid + + G = grid.graph - if G is None: - G = edisgo_obj.topology.mv_grid.graph + logger.debug(f"selected_timesteps={selected_timesteps}") - if hasattr(grid, "transformers_df"): - node_root = grid.transformers_df.bus1.iat[0] - x_root, y_root = G.nodes[node_root]["pos"] + if isinstance(selected_timesteps, pd.Timestamp) or isinstance( + selected_timesteps, str + ): + selected_timesteps = [selected_timesteps] + + if selected_timesteps is None: + selected_timesteps = edisgo_obj.results.s_res.index + + if edisgo_obj.results.s_res.empty: + power_flow_results = False + warning_message = "No power flow results. -> Run power flow." + elif len(selected_timesteps) == 0: + power_flow_results = False + warning_message = "No time steps selected." + else: + power_flow_results = True + warning_message = False - elif not grid: + try: + edisgo_obj.results.s_res.loc[selected_timesteps, :] + except KeyError: + power_flow_results = False + warning_message = "Time steps are not in the results." + + # check for existing reinforcement results + if edisgo_obj.results.equipment_changes.empty: + reinforcement_results = False + else: + reinforcement_results = True + + # check line_color input + line_color_options = ["loading", "relative_loading", "reinforce"] + if line_color not in line_color_options: + logger.warning(f"Line colors need to be one of {line_color_options}.") + line_color = None + elif (line_color in ["loading", "relative_loading"]) and (not power_flow_results): + logger.warning("No power flow results to show. -> Run power flow.") + line_color = None + elif (line_color in ["reinforce"]) and (not reinforcement_results): + logger.warning("No reinforcement results to show. -> Run reinforcement.") + line_color = None + + # check node_color input + node_color_options = ["voltage_deviation", "adjacencies"] + if node_color not in node_color_options: + logger.warning(f"Line colors need to be one of {node_color_options}.") + node_color = None + elif (node_color in ["voltage_deviation"]) and (not power_flow_results): + logger.warning("No power flow results to show. -> Run power flow.") + node_color = None + + if center_coordinates: + # Center transformer coordinates on (0,0). + if hasattr(grid, "transformers_df"): + node_root = grid.transformers_df.bus1.iat[0] + x_root, y_root = G.nodes[node_root]["pos"] + else: + node_root = edisgo_obj.topology.transformers_hvmv_df.bus1.iat[0] + x_root, y_root = G.nodes[node_root]["pos"] + else: x_root = 0 y_root = 0 - else: - node_root = edisgo_obj.topology.transformers_hvmv_df.bus1.iat[0] - x_root, y_root = G.nodes[node_root]["pos"] + if pseudo_coordinates: + G = make_pseudo_coordinates_graph( + G, edisgo_obj.config["grid_connection"]["branch_detour_factor"] + ) - x_root, y_root = transformer_4326_to_3035.transform(x_root, y_root) + if node_selection: + G = G.subgraph(node_selection) + if not list(G.nodes()): + raise ValueError("Selected nodes are not in the selected grid.") - # line text - middle_node_x = [] - middle_node_y = [] - middle_node_text = [] + # Select values for displaying results. + if power_flow_results: + s_res_view = edisgo_obj.results.s_res.columns.isin( + [edge[2]["branch_name"] for edge in G.edges.data()] + ) + v_res_view = edisgo_obj.results.v_res.columns.isin([node for node in G.nodes]) - for edge in G.edges(data=True): + s_res = edisgo_obj.results.s_res.loc[selected_timesteps, s_res_view] + v_res = edisgo_obj.results.v_res.loc[selected_timesteps, v_res_view] + + result_selection_options = ["min", "max"] + if line_result_selection == "min": + s_res = s_res.min() + elif line_result_selection == "max": + s_res = s_res.max() + else: + raise ValueError( + f"line_result_selection needs to be one of {result_selection_options}" + ) + if node_result_selection == "min": + v_res = v_res.min() + elif node_result_selection == "max": + v_res = v_res.max() + else: + raise ValueError( + f"node_result_selection needs to be one of {result_selection_options}" + ) + + # initialization coordinate transformation + transformer_4326_to_3035 = Transformer.from_crs( + "EPSG:4326", + "EPSG:3035", + always_xy=True, + ) + + def get_coordinates_for_edge(edge): x0, y0 = G.nodes[edge[0]]["pos"] x1, y1 = G.nodes[edge[1]]["pos"] x0, y0 = transformer_4326_to_3035.transform(x0, y0) x1, y1 = transformer_4326_to_3035.transform(x1, y1) - middle_node_x.append((x0 - x_root + x1 - x_root) / 2) - middle_node_y.append((y0 - y_root + y1 - y_root) / 2) + return x0, y0, x1, y1 - branch_name = edge[2]["branch_name"] + x_root, y_root = transformer_4326_to_3035.transform(x_root, y_root) - text = str(branch_name) - try: - loading = edisgo_obj.results.s_res.T.loc[branch_name].max() - text += "
" + "Loading = " + str(loading) - except KeyError: - logger.debug( - f"Could not find loading for branch {branch_name}", exc_info=True - ) - text = text + def plot_line_text(): + middle_node_x = [] + middle_node_y = [] + middle_node_text = [] + + for edge in G.edges(data=True): + x0, y0, x1, y1 = get_coordinates_for_edge(edge) + middle_node_x.append((x0 - x_root + x1 - x_root) / 2) + middle_node_y.append((y0 - y_root + y1 - y_root) / 2) + + branch_name = edge[2]["branch_name"] + + text = str(branch_name) + if power_flow_results: + text += "
" + "Loading = " + str(s_res.loc[branch_name]) - try: line_parameters = edisgo_obj.topology.lines_df.loc[branch_name, :] for index, value in line_parameters.iteritems(): text += "
" + str(index) + " = " + str(value) - except KeyError: - logger.debug( - f"Could not find line parameters for branch {branch_name}", - exc_info=True, - ) - text = text - middle_node_text.append(text) - - middle_node_trace = go.Scatter( - x=middle_node_x, - y=middle_node_y, - text=middle_node_text, - mode="markers", - hoverinfo="text", - marker=dict(opacity=0.0, size=10, color="white"), - ) + middle_node_text.append(text) - data = [middle_node_trace] - - # line plot - if line_color == "loading": - s_res_view = edisgo_obj.results.s_res.T.index.isin( - [edge[2]["branch_name"] for edge in G.edges.data()] + middle_node_scatter = go.Scatter( + x=middle_node_x, + y=middle_node_y, + text=middle_node_text, + mode="markers", + hoverinfo="text", + marker=dict( + opacity=0.0, + size=10, + color="white", + ), + showlegend=False, ) - color_min = edisgo_obj.results.s_res.T.loc[s_res_view].T.min().max() - color_max = edisgo_obj.results.s_res.T.loc[s_res_view].T.max().max() + return [middle_node_scatter] - elif line_color == "relative_loading": - color_min = 0 - color_max = 1 - - for edge in G.edges(data=True): - x0, y0 = G.nodes[edge[0]]["pos"] - x1, y1 = G.nodes[edge[1]]["pos"] - x0, y0 = transformer_4326_to_3035.transform(x0, y0) - x1, y1 = transformer_4326_to_3035.transform(x1, y1) + def plot_lines(): - edge_x = [x0 - x_root, x1 - x_root, None] - edge_y = [y0 - y_root, y1 - y_root, None] - - if line_color == "reinforce": - if edisgo_obj.results.grid_expansion_costs.index.isin( - [edge[2]["branch_name"]] - ).any(): - color = "lightgreen" - else: - color = "black" - - elif line_color == "loading": - loading = edisgo_obj.results.s_res.T.loc[edge[2]["branch_name"]].max() - color = color_map_color( - loading, - vmin=color_min, - vmax=color_max, - ) + showscale = True + if line_color == "loading": + color_min = s_res.min() + color_max = s_res.max() + colorscale = "YlOrRd" elif line_color == "relative_loading": - loading = edisgo_obj.results.s_res.T.loc[edge[2]["branch_name"]].max() - s_nom = edisgo_obj.topology.lines_df.s_nom.loc[edge[2]["branch_name"]] - color = color_map_color( - loading / s_nom, - vmin=color_min, - vmax=color_max, - ) - if loading > s_nom: - color = "green" + color_min = 0 + color_max = 1 + colorscale = [ + [0, "yellow"], + [0.45, "orange"], + [0.9, "crimson"], + [0.9, "indigo"], + [1, "indigo"], + ] + elif line_color == "reinforce": + color_min = 0 + color_max = 1 + colorscale = [[0, "green"], [0.5, "green"], [0.5, "red"], [1, "red"]] else: - color = "black" - - edge_trace = go.Scatter( - x=edge_x, - y=edge_y, - hoverinfo="none", - opacity=0.4, - mode="lines", - line=dict(width=2, color=color), - ) - data.append(edge_trace) + showscale = False - # node plot - node_x = [] - node_y = [] + data_line_plot = [] + for edge in G.edges(data=True): - for node in G.nodes(): - x, y = G.nodes[node]["pos"] - x, y = transformer_4326_to_3035.transform(x, y) - node_x.append(x - x_root) - node_y.append(y - y_root) + x0, y0, x1, y1 = get_coordinates_for_edge(edge) + edge_x = [x0 - x_root, x1 - x_root, None] + edge_y = [y0 - y_root, y1 - y_root, None] - if node_color == "voltage_deviation": - colors = [] + branch_name = edge[2]["branch_name"] - for node in G.nodes(): - v_res = edisgo_obj.results.v_res.T.loc[node] - v_min = v_res.min() - v_max = v_res.max() + if line_color == "reinforce": + # Possible distinction between added parallel + # lines and changed lines + if ( + edisgo_obj.results.equipment_changes.index[ + edisgo_obj.results.equipment_changes["change"] == "added" + ] + .isin([branch_name]) + .any() + ): + color = "green" + # Changed lines + elif ( + edisgo_obj.results.equipment_changes.index[ + edisgo_obj.results.equipment_changes["change"] == "changed" + ] + .isin([branch_name]) + .any() + ): + + color = "red" + else: + color = "black" + + elif line_color == "loading": + loading = s_res.loc[branch_name] + color = color_map_color( + loading, + vmin=color_min, + vmax=color_max, + cmap_name=colorscale, + ) - if abs(v_min - 1) > abs(v_max - 1): - color = v_min - 1 + elif line_color == "relative_loading": + loading = s_res.loc[branch_name] + s_nom = edisgo_obj.topology.lines_df.s_nom.loc[branch_name] + color = color_map_color( + loading / s_nom, + vmin=color_min, + vmax=color_max, + cmap_name=colorscale, + ) + if loading > s_nom: + color = "indigo" else: - color = v_max - 1 + color = "grey" + + edge_scatter = go.Scatter( + mode="lines", + x=edge_x, + y=edge_y, + hoverinfo="none", + opacity=0.8, + showlegend=False, + line=dict( + width=2, + color=color, + ), + ) + data_line_plot.append(edge_scatter) - colors.append(color) + if line_color: + line_color_title = { + "loading": "Loading in MVA", + "relative_loading": "Relative loading in p.u.", + "reinforce": "Reinforce", + } - colorbar = dict( - thickness=15, - title="Node Voltage Deviation", - xanchor="left", - titleside="right", - ) - colorscale = "RdBu" - cmid = 0 + colorbar_edge_scatter = go.Scatter( + mode="markers", + x=[None], + y=[None], + marker=dict( + colorbar=dict( + title=line_color_title[line_color], + xanchor="left", + titleside="right", + x=1.19, + thickness=15, + ), + colorscale=colorscale, + cmax=color_max, + cmin=color_min, + showscale=showscale, + ), + ) - else: - colors = [len(adjacencies[1]) for adjacencies in G.adjacency()] - colorscale = "YlGnBu" - cmid = None + if line_color == "reinforce": + colorbar_edge_scatter.marker.colorbar.tickmode = "array" + colorbar_edge_scatter.marker.colorbar.ticktext = ["added", "changed"] + colorbar_edge_scatter.marker.colorbar.tickvals = [0.25, 0.75] + elif line_color == "relative_loading": + colorbar_edge_scatter.marker.colorbar.tickmode = "array" + colorbar_edge_scatter.marker.colorbar.ticktext = [ + 0, + 0.2, + 0.4, + 0.6, + 0.8, + 1, + "Overloaded", + ] + colorbar_edge_scatter.marker.colorbar.tickvals = [ + 0, + 0.2 * 0.9, + 0.4 * 0.9, + 0.6 * 0.9, + 0.8 * 0.9, + 1 * 0.9, + 0.95, + ] + data_line_plot.append(colorbar_edge_scatter) + + return data_line_plot + + def plot_buses(): + node_x = [] + node_y = [] - colorbar = dict( - thickness=15, title="Node Connections", xanchor="left", titleside="right" - ) + for node in G.nodes(): + x, y = G.nodes[node]["pos"] + x, y = transformer_4326_to_3035.transform(x, y) + node_x.append(x - x_root) + node_y.append(y - y_root) + + if node_color == "voltage_deviation": + node_colors = [] + for node in G.nodes(): + color = v_res.loc[node] - 1 + node_colors.append(color) + + colorbar = dict( + thickness=15, + title="Node voltage deviation in p.u.", + xanchor="left", + titleside="right", + ) + colorscale = "RdBu" + cmid = 0 + showscale = True + + elif node_color == "adjacencies": + node_colors = [len(adjacencies[1]) for adjacencies in G.adjacency()] + colorscale = "YlGnBu" + cmid = None + + colorbar = dict( + thickness=15, + title="Node connections", + xanchor="left", + titleside="right", + ) + showscale = True - node_text = [] - for node in G.nodes(): - text = str(node) - try: - peak_load = edisgo_obj.topology.loads_df.loc[ - edisgo_obj.topology.loads_df.bus == node - ].p_set.sum() - text += "
" + "peak_load = " + str(peak_load) - p_nom = edisgo_obj.topology.generators_df.loc[ - edisgo_obj.topology.generators_df.bus == node - ].p_nom.sum() - text += "
" + "p_nom_gen = " + str(p_nom) - v_min = edisgo_obj.results.v_res.T.loc[node].min() - v_max = edisgo_obj.results.v_res.T.loc[node].max() - if abs(v_min - 1) > abs(v_max - 1): - text += "
" + "v = " + str(v_min) - else: - text += "
" + "v = " + str(v_max) - except KeyError: - logger.debug(f"Failed to add text for node {node}.", exc_info=True) - text = text + else: + node_colors = "grey" + cmid = None + colorscale = None + colorbar = None + showscale = False + + node_text = [] + for node in G.nodes(): + text = str(node) + if power_flow_results: + peak_load = edisgo_obj.topology.loads_df.loc[ + edisgo_obj.topology.loads_df.bus == node + ].p_set.sum() + text += "
" + "peak_load = " + str(peak_load) + + p_nom = edisgo_obj.topology.generators_df.loc[ + edisgo_obj.topology.generators_df.bus == node + ].p_nom.sum() + text += "
" + "p_nom_gen = " + str(p_nom) + + v = v_res.loc[node] + text += "
" + "v = " + str(v) - try: text = text + "
" + "Neighbors = " + str(G.degree(node)) - except KeyError: - logger.debug( - f"Failed to add neighbors to text for node {node}.", exc_info=True - ) - text = text - try: node_parameters = edisgo_obj.topology.buses_df.loc[node] for index, value in node_parameters.iteritems(): text += "
" + str(index) + " = " + str(value) - except KeyError: - logger.debug( - f"Failed to add neighbors to text for node {node}.", exc_info=True - ) - text = text - - node_text.append(text) - - node_trace = go.Scatter( - x=node_x, - y=node_y, - mode="markers", - hoverinfo="text", - text=node_text, - marker=dict( - showscale=True, - colorscale=colorscale, - reversescale=True, - color=colors, - size=8, - cmid=cmid, - line_width=2, - colorbar=colorbar, - ), - ) - data.append(node_trace) + node_text.append(text) + + node_scatter = go.Scatter( + x=node_x, + y=node_y, + mode="markers", + hoverinfo="text", + text=node_text, + marker=dict( + showscale=showscale, + colorscale=colorscale, + color=node_colors, + size=8, + cmid=cmid, + line_width=2, + colorbar=colorbar, + ), + ) + return [node_scatter] fig = go.Figure( - data=data, + data=plot_line_text() + plot_lines() + plot_buses(), layout=go.Layout( height=500, - titlefont_size=16, showlegend=False, hovermode="closest", margin=dict(b=20, l=5, r=5, t=40), - xaxis=dict(showgrid=True, zeroline=True, showticklabels=True), - yaxis=dict(showgrid=True, zeroline=True, showticklabels=True), + xaxis=dict( + showgrid=True, + zeroline=True, + showticklabels=True, + ), + yaxis=dict( + showgrid=True, + zeroline=True, + showticklabels=True, + scaleanchor="x", + scaleratio=1, + ), ), ) - - fig.update_yaxes(scaleanchor="x", scaleratio=1) - + if warning_message: + fig.add_annotation( + x=0, + y=1, + xref="paper", + yref="paper", + xanchor="left", + text=warning_message, + showarrow=False, + font=dict(size=16, color="#ffffff"), + bgcolor="red", + opacity=0.75, + ) return fig @@ -1244,32 +1469,27 @@ def chosen_graph( return G, grid -def dash_plot( - edisgo_objects: EDisGo | dict[str, EDisGo], - line_plot_modes: list[str] | None = None, - node_plot_modes: list[str] | None = None, +def plot_dash_app( + edisgo_objects: EDisGo | dict[str, EDisGo], debug: bool = False ) -> JupyterDash: """ Generates a jupyter dash app from given eDisGo object(s). - TODO: The app doesn't display two seperate colorbars for line and bus values atm - Parameters ---------- edisgo_objects : :class:`~.EDisGo` or dict[str, :class:`~.EDisGo`] eDisGo objects to show in plotly dash app. In the case of multiple edisgo objects pass a dictionary with the eDisGo objects as values and the respective eDisGo object names as keys. - line_plot_modes : list(str), optional - List of line plot modes to display in plotly dash app. See - :py:func:`~edisgo.tools.plots.draw_plotly` for more information. If None is - passed the modes 'reinforce', 'loading' and 'relative_loading' will be used. - Default: None - node_plot_modes : list(str), optional - List of line plot modes to display in plotly dash app. See - :py:func:`~edisgo.tools.plots.draw_plotly` for more information. If None is - passed the modes 'adjacencies' and 'voltage_deviation' will be used. - Default: None + + debug : bool + Debugging for the dash app: + + * False (default) + Disable debugging for the dash app. + * True + Enable debugging for the dash app. + Returns ------- @@ -1280,54 +1500,262 @@ def dash_plot( if isinstance(edisgo_objects, dict): edisgo_name_list = list(edisgo_objects.keys()) edisgo_obj_1 = list(edisgo_objects.values())[0] + + edisgo_obj_1_mv_grid_name = str(edisgo_obj_1.topology.mv_grid) + for edisgo_obj in edisgo_objects.values(): + if edisgo_obj_1_mv_grid_name != str(edisgo_obj.topology.mv_grid): + raise ValueError("edisgo_objects are not matching.") + else: edisgo_name_list = ["edisgo_obj"] edisgo_obj_1 = edisgo_objects - grid_name_list = ["Grid"] + edisgo_obj_1.topology._grids_repr + mv_grid = edisgo_obj_1.topology.mv_grid + lv_grid_name_list = list(map(str, mv_grid.lv_grids)) + grid_name_list = ["Grid", str(mv_grid)] + lv_grid_name_list + + line_plot_modes = ["relative_loading", "loading", "reinforce"] + node_plot_modes = ["voltage_deviation", "adjacencies"] + + if edisgo_obj_1.results.v_res.empty: + timestep_values = ["No results"] + timestep_labels = ["No results"] + elif edisgo_obj_1.timeseries.is_worst_case: + timestep_values = edisgo_obj_1.results.v_res.index.to_list() + worst_case_series = edisgo_obj_1.timeseries.timeindex_worst_cases + timestep_labels = [ + worst_case_series.index[worst_case_series.to_list().index(value)] + for value in timestep_values + ] + else: + timestep_labels = edisgo_obj_1.results.v_res.index.to_list() + timestep_values = edisgo_obj_1.results.v_res.index.to_list() + + logger.debug(f"timestep_labels={timestep_labels}") + logger.debug(f"timestep_values={timestep_values}") + timestep_option = [ + {"label": timestep_labels[i], "value": str(timestep_values[i])} + for i in range(0, len(timestep_values)) + ] + logger.debug(f"timestep_option={timestep_option}") - if line_plot_modes is None: - line_plot_modes = ["reinforce", "loading", "relative_loading"] - if node_plot_modes is None: - node_plot_modes = ["adjacencies", "voltage_deviation"] + padding = 1 app = JupyterDash(__name__) + # Workaround to use standard python logging with plotly dash + logger.handlers.pop() + if debug: + app.logger.disabled = False + app.logger.setLevel(logging.DEBUG) if isinstance(edisgo_objects, dict) and len(edisgo_objects) > 1: app.layout = html.Div( [ html.Div( [ - dcc.Dropdown( - id="dropdown_edisgo_object_1", - options=[ - {"label": i, "value": i} for i in edisgo_name_list + html.Div( + [ + html.Label("Edisgo objects"), ], - value=edisgo_name_list[0], + style={"padding": padding, "flex": 1}, ), - dcc.Dropdown( - id="dropdown_edisgo_object_2", - options=[ - {"label": i, "value": i} for i in edisgo_name_list + html.Div( + [], + style={"padding": padding, "flex": 1}, + ), + ], + style={ + "display": "flex", + "flex-direction": "row", + "padding": 0, + "flex": 1, + }, + ), + html.Div( + [ + html.Div( + [ + dcc.Dropdown( + id="dropdown_edisgo_object_1", + options=[ + {"label": i, "value": i} + for i in edisgo_name_list + ], + value=edisgo_name_list[0], + ), ], - value=edisgo_name_list[1], + style={"padding": padding, "flex": 1}, ), - dcc.Dropdown( - id="dropdown_grid", - options=[{"label": i, "value": i} for i in grid_name_list], - value=grid_name_list[1], + html.Div( + [ + dcc.Dropdown( + id="dropdown_edisgo_object_2", + options=[ + {"label": i, "value": i} + for i in edisgo_name_list + ], + value=edisgo_name_list[1], + ), + ], + style={"padding": padding, "flex": 1}, ), - dcc.Dropdown( - id="dropdown_line_plot_mode", - options=[{"label": i, "value": i} for i in line_plot_modes], - value=line_plot_modes[0], + ], + style={ + "display": "flex", + "flex-direction": "row", + "padding": 0, + "flex": 1, + }, + ), + html.Div( + [ + html.Div( + [ + html.Label("Grid"), + dcc.Dropdown( + id="dropdown_grid", + options=[ + {"label": i, "value": i} for i in grid_name_list + ], + value=grid_name_list[1], + ), + ], + style={"padding": padding, "flex": 1}, ), - dcc.Dropdown( - id="dropdown_node_plot_mode", - options=[{"label": i, "value": i} for i in node_plot_modes], - value=node_plot_modes[0], + html.Div( + [ + html.Label("Line plot mode"), + dcc.Dropdown( + id="dropdown_line_plot_mode", + options=[ + {"label": i, "value": i} + for i in line_plot_modes + ], + value=line_plot_modes[0], + ), + ], + style={"padding": padding, "flex": 1}, ), - ] + html.Div( + [ + html.Label("Line result selection"), + dcc.Dropdown( + id="line_result_selection", + options=[ + {"label": "Min", "value": "min"}, + {"label": "Max", "value": "max"}, + ], + value="max", + ), + ], + style={"padding": padding, "flex": 1}, + ), + ], + style={ + "display": "flex", + "flex-direction": "row", + "padding": 0, + "flex": 1, + }, + ), + html.Div( + [ + html.Div( + [ + html.Label("Pseudo coordinates"), + dcc.RadioItems( + id="radioitems_pseudo_coordinates", + options=[ + {"label": "False", "value": False}, + {"label": "True", "value": True}, + ], + value=False, + ), + ], + style={"padding": padding, "flex": 1}, + ), + html.Div( + [ + html.Label("Node plot mode"), + dcc.Dropdown( + id="dropdown_node_plot_mode", + options=[ + {"label": i, "value": i} + for i in node_plot_modes + ], + value=node_plot_modes[0], + ), + ], + style={"padding": padding, "flex": 1}, + ), + html.Div( + [ + html.Label("Node result selection"), + dcc.Dropdown( + id="node_result_selection", + options=[ + {"label": "Min", "value": "min"}, + {"label": "Max", "value": "max"}, + ], + value="max", + ), + ], + style={"padding": padding, "flex": 1}, + ), + ], + style={ + "display": "flex", + "flex-direction": "row", + "padding": 0, + "flex": 1, + }, + ), + html.Div( + [ + html.Div( + [ + html.Label( + f"Time step mode - " + f"Time steps of {edisgo_name_list[0]}" + ), + dcc.RadioItems( + ["Single", "Range", "All"], + "All", + inline=True, + id="timestep_mode_radio", + ), + ], + style={"padding": padding, "flex": 1}, + ), + html.Div( + [ + html.Label("Time step start"), + dcc.Dropdown( + id="timestep_dropdown_start", + options=timestep_option, + value=timestep_option[0]["value"], + ), + ], + style={"padding": padding, "flex": 1}, + ), + html.Div( + [ + html.Label("Time step end"), + dcc.Dropdown( + id="timestep_dropdown_end", + options=timestep_option, + value=timestep_option[-1]["value"], + ), + ], + style={"padding": padding, "flex": 1}, + ), + ], + style={ + "display": "flex", + "flex-direction": "row", + "padding": 0, + "flex": 1, + }, ), html.Div( [ @@ -1340,40 +1768,102 @@ def dash_plot( style={"display": "flex", "flex-direction": "column"}, ) + @app.callback( + Output("timestep_dropdown_start", "disabled"), + Output("timestep_dropdown_end", "disabled"), + Input("timestep_mode_radio", "value"), + ) + def update_timestep_components_double(timestep_mode_radio): + if timestep_mode_radio == "Single": + timestep_dropdown_start = False + timestep_dropdown_end = True + elif timestep_mode_radio == "Range": + timestep_dropdown_start = False + timestep_dropdown_end = False + elif timestep_mode_radio == "All": + timestep_dropdown_start = True + timestep_dropdown_end = True + return (timestep_dropdown_start, timestep_dropdown_end) + @app.callback( Output("fig_1", "figure"), Output("fig_2", "figure"), - Input("dropdown_grid", "value"), Input("dropdown_edisgo_object_1", "value"), Input("dropdown_edisgo_object_2", "value"), + Input("dropdown_grid", "value"), Input("dropdown_line_plot_mode", "value"), Input("dropdown_node_plot_mode", "value"), + Input("radioitems_pseudo_coordinates", "value"), + Input("line_result_selection", "value"), + Input("node_result_selection", "value"), + Input("timestep_mode_radio", "value"), + Input("timestep_dropdown_start", "value"), + Input("timestep_dropdown_end", "value"), + log=True, ) - def update_figure( - selected_grid, + def update_figure_double( selected_edisgo_object_1, selected_edisgo_object_2, + selected_grid, selected_line_plot_mode, selected_node_plot_mode, + pseudo_coordinates, + line_result_selection, + node_result_selection, + timestep_mode, + timestep_dropdown_start, + timestep_dropdown_end, ): edisgo_obj = edisgo_objects[selected_edisgo_object_1] (G, grid) = chosen_graph(edisgo_obj, selected_grid) - fig_1 = draw_plotly( - edisgo_obj, - G, - selected_line_plot_mode, - selected_node_plot_mode, + + if timestep_mode == "Single": + selected_timesteps = timestep_dropdown_start + elif timestep_mode == "Range": + app.logger.debug( + f"timestep_dropdown_start={timestep_dropdown_start}, " + f"timestep_dropdown_end={timestep_dropdown_end}" + ) + if timestep_dropdown_start == timestep_dropdown_end: + selected_timesteps = timestep_dropdown_start + else: + selected_timesteps = edisgo_obj.results.v_res.loc[ + timestep_dropdown_start:timestep_dropdown_end, : + ].index.to_list() + if selected_timesteps == []: + selected_timesteps = edisgo_obj.results.v_res.loc[ + timestep_dropdown_end:timestep_dropdown_start, : + ].index.to_list() + elif timestep_mode == "All": + selected_timesteps = False + + app.logger.debug(f"selected_timesteps={selected_timesteps}") + + fig_1 = plot_plotly( + edisgo_obj=edisgo_obj, grid=grid, + line_color=selected_line_plot_mode, + node_color=selected_node_plot_mode, + line_result_selection=line_result_selection, + node_result_selection=node_result_selection, + selected_timesteps=selected_timesteps, + pseudo_coordinates=pseudo_coordinates, + center_coordinates=True, ) edisgo_obj = edisgo_objects[selected_edisgo_object_2] (G, grid) = chosen_graph(edisgo_obj, selected_grid) - fig_2 = draw_plotly( - edisgo_obj, - G, - selected_line_plot_mode, - selected_node_plot_mode, + + fig_2 = plot_plotly( + edisgo_obj=edisgo_obj, grid=grid, + line_color=selected_line_plot_mode, + node_color=selected_node_plot_mode, + line_result_selection=line_result_selection, + node_result_selection=node_result_selection, + selected_timesteps=selected_timesteps, + pseudo_coordinates=pseudo_coordinates, + center_coordinates=True, ) return fig_1, fig_2 @@ -1383,22 +1873,150 @@ def update_figure( [ html.Div( [ - dcc.Dropdown( - id="dropdown_grid", - options=[{"label": i, "value": i} for i in grid_name_list], - value=grid_name_list[1], + html.Div( + [ + html.Label("Grid"), + dcc.Dropdown( + id="dropdown_grid", + options=[ + {"label": i, "value": i} for i in grid_name_list + ], + value=grid_name_list[1], + ), + ], + style={"padding": padding, "flex": 1}, ), - dcc.Dropdown( - id="dropdown_line_plot_mode", - options=[{"label": i, "value": i} for i in line_plot_modes], - value=line_plot_modes[0], + html.Div( + [ + html.Label("Line plot mode"), + dcc.Dropdown( + id="dropdown_line_plot_mode", + options=[ + {"label": i, "value": i} + for i in line_plot_modes + ], + value=line_plot_modes[0], + ), + ], + style={"padding": padding, "flex": 1}, ), - dcc.Dropdown( - id="dropdown_node_plot_mode", - options=[{"label": i, "value": i} for i in node_plot_modes], - value=node_plot_modes[0], + html.Div( + [ + html.Label("Line result selection"), + dcc.Dropdown( + id="line_result_selection", + options=[ + {"label": "Min", "value": "min"}, + {"label": "Max", "value": "max"}, + ], + value="max", + ), + ], + style={"padding": padding, "flex": 1}, ), - ] + ], + style={ + "display": "flex", + "flex-direction": "row", + "padding": 0, + "flex": 1, + }, + ), + html.Div( + [ + html.Div( + [ + html.Label("Pseudo coordinates"), + dcc.RadioItems( + id="radioitems_pseudo_coordinates", + options=[ + {"label": "False", "value": False}, + {"label": "True", "value": True}, + ], + value=False, + ), + ], + style={"padding": padding, "flex": 1}, + ), + html.Div( + [ + html.Label("Node plot mode"), + dcc.Dropdown( + id="dropdown_node_plot_mode", + options=[ + {"label": i, "value": i} + for i in node_plot_modes + ], + value=node_plot_modes[0], + ), + ], + style={"padding": padding, "flex": 1}, + ), + html.Div( + [ + html.Label("Node result selection"), + dcc.Dropdown( + id="node_result_selection", + options=[ + {"label": "Min", "value": "min"}, + {"label": "Max", "value": "max"}, + ], + value="max", + ), + ], + style={"padding": padding, "flex": 1}, + ), + ], + style={ + "display": "flex", + "flex-direction": "row", + "padding": 0, + "flex": 1, + }, + ), + html.Div( + [ + html.Div( + [ + html.Label("Time step mode"), + dcc.RadioItems( + ["Single", "Range", "All"], + "All", + inline=True, + id="timestep_mode_radio", + ), + ], + style={"padding": padding, "flex": 1}, + ), + html.Div( + [ + html.Label("Time step start"), + dcc.Dropdown( + id="timestep_dropdown_start", + options=timestep_option, + value=timestep_option[0]["value"], + ), + ], + style={"padding": padding, "flex": 1}, + ), + html.Div( + [ + html.Label("Time step end"), + dcc.Dropdown( + id="timestep_dropdown_end", + options=timestep_option, + value=timestep_option[-1]["value"], + ), + ], + style={"padding": padding, "flex": 1}, + ), + ], + style={ + "display": "flex", + "flex-direction": "row", + "padding": 0, + "flex": 1, + }, ), html.Div( [html.Div([dcc.Graph(id="fig")], style={"flex": "auto"})], @@ -1408,23 +2026,121 @@ def update_figure( style={"display": "flex", "flex-direction": "column"}, ) + @app.callback( + Output("timestep_dropdown_start", "disabled"), + Output("timestep_dropdown_end", "disabled"), + Input("timestep_mode_radio", "value"), + ) + def update_timestep_components_single(timestep_mode_radio): + if timestep_mode_radio == "Single": + timestep_dropdown_start = False + timestep_dropdown_end = True + elif timestep_mode_radio == "Range": + timestep_dropdown_start = False + timestep_dropdown_end = False + elif timestep_mode_radio == "All": + timestep_dropdown_start = True + timestep_dropdown_end = True + return (timestep_dropdown_start, timestep_dropdown_end) + @app.callback( Output("fig", "figure"), Input("dropdown_grid", "value"), Input("dropdown_line_plot_mode", "value"), Input("dropdown_node_plot_mode", "value"), + Input("radioitems_pseudo_coordinates", "value"), + Input("line_result_selection", "value"), + Input("node_result_selection", "value"), + Input("timestep_mode_radio", "value"), + Input("timestep_dropdown_start", "value"), + Input("timestep_dropdown_end", "value"), + log=True, ) - def update_figure( - selected_grid, selected_line_plot_mode, selected_node_plot_mode + def update_figure_single( + selected_grid, + selected_line_plot_mode, + selected_node_plot_mode, + pseudo_coordinates, + line_result_selection, + node_result_selection, + timestep_mode, + timestep_dropdown_start, + timestep_dropdown_end, ): + if timestep_mode == "Single": + selected_timesteps = timestep_dropdown_start + elif timestep_mode == "Range": + app.logger.debug(f"timestep_dropdown_start={timestep_dropdown_start}") + app.logger.debug(f"timestep_dropdown_end={timestep_dropdown_end}") + + if timestep_dropdown_start == timestep_dropdown_end: + selected_timesteps = str(timestep_dropdown_start) + else: + selected_timesteps = edisgo_obj_1.results.v_res.loc[ + timestep_dropdown_start:timestep_dropdown_end, : + ].index + if len(selected_timesteps) == 0: + selected_timesteps = edisgo_obj_1.results.v_res.loc[ + timestep_dropdown_end:timestep_dropdown_start, : + ].index + selected_timesteps = list(map(str, selected_timesteps)) + elif timestep_mode == "All": + selected_timesteps = False + + app.logger.debug(f"selected_timesteps={selected_timesteps}") + (G, grid) = chosen_graph(edisgo_obj_1, selected_grid) - fig = draw_plotly( - edisgo_obj_1, - G, - selected_line_plot_mode, - selected_node_plot_mode, + fig = plot_plotly( + edisgo_obj=edisgo_obj_1, grid=grid, + line_color=selected_line_plot_mode, + node_color=selected_node_plot_mode, + line_result_selection=line_result_selection, + node_result_selection=node_result_selection, + selected_timesteps=selected_timesteps, + pseudo_coordinates=pseudo_coordinates, + center_coordinates=True, ) + return fig return app + + +def plot_dash( + edisgo_objects: EDisGo | dict[str, EDisGo], + mode: str = "inline", + debug: bool = False, + port: int = 8050, +): + """ + Shows the generated jupyter dash app from given eDisGo object(s). + + Parameters + ---------- + edisgo_objects : :class:`~.EDisGo` or dict[str, :class:`~.EDisGo`] + eDisGo objects to show in plotly dash app. In the case of multiple edisgo + objects pass a dictionary with the eDisGo objects as values and the respective + eDisGo object names as keys. + + mode : str + Display mode + + * "inline" (default) + Jupyter lab inline plotting. + * "jupyterlab" + Plotting in own Jupyter lab tab. + * "external" + Plotting in own browser tab. + + debug : bool + If True, enables debugging of the jupyter dash app. + + port : int + Port which the app uses. Default: 8050. + + """ + app = plot_dash_app(edisgo_objects, debug=debug) + log = logging.getLogger("werkzeug") + log.setLevel(logging.ERROR) + app.run_server(mode=mode, debug=debug, height=820, port=port) diff --git a/edisgo/tools/pseudo_coordinates.py b/edisgo/tools/pseudo_coordinates.py new file mode 100644 index 000000000..a42654ec9 --- /dev/null +++ b/edisgo/tools/pseudo_coordinates.py @@ -0,0 +1,272 @@ +from __future__ import annotations + +import copy +import logging +import math + +from time import time +from typing import TYPE_CHECKING + +import networkx as nx + +from pyproj import Transformer + +if TYPE_CHECKING: + from networkx import Graph + + from edisgo import EDisGo + +logger = logging.getLogger(__name__) + +# Transform coordinates to equidistant and back +coor_transform = Transformer.from_crs("EPSG:4326", "EPSG:3035", always_xy=True) +coor_transform_back = Transformer.from_crs("EPSG:3035", "EPSG:4326", always_xy=True) + + +# Pseudo coordinates +def _make_coordinates(graph_root: Graph, branch_detour_factor: float) -> Graph: + """ + Generates pseudo coordinates for a graph with equidistant coordinates. + + Parameters + ---------- + graph_root : :networkx:`networkx.Graph` + Graph object to generate pseudo coordinates for (with equidistant coordinates). + + branch_detour_factor : float + Defines the quotient of the line length and the distance of the buses. + + Returns + ------- + :networkx:`networkx.Graph` + Graph with equidistant pseudo coordinates for all nodes. + + """ + + # Make coordinates for the neighbours of the transformer node + # Nodes are distributed around the source node with the same angle + def coordinate_source(pos_start, length, node_numerator, node_total_numerator): + length = length / branch_detour_factor + angle = node_numerator * 360 / node_total_numerator + x0, y0 = pos_start + x1 = x0 + 1000 * length * math.cos(math.radians(angle)) + y1 = y0 + 1000 * length * math.sin(math.radians(angle)) + pos_end = (x1, y1) + origin_angle = math.degrees(math.atan2(y1 - y0, x1 - x0)) + return pos_end, origin_angle + + # Make coordinates for nodes which are not neighbors of the transformer, + # not in the longest path, not neighbors of the longest path. + # Nodes are placed in the half plane generated by the straight longest path, + # the angle between the neighbouring nodes are same, basically a tree. + def coordinate_branch( + pos_start, angle_offset, length, node_numerator, node_total_numerator + ): + length = length / branch_detour_factor + angle = node_numerator * 180 / (node_total_numerator + 1) + angle_offset - 90 + x0, y0 = pos_start + x1 = x0 + 1000 * length * math.cos(math.radians(angle)) + y1 = y0 + 1000 * length * math.sin(math.radians(angle)) + origin_angle = math.degrees(math.atan2(y1 - y0, x1 - x0)) + pos_end = (x1, y1) + return pos_end, origin_angle + + # Make coordinates for the nodes of the longest path + # Nodes are distributed in a straight line + def coordinate_longest_path(pos_start, angle_offset, length): + length = length / branch_detour_factor + angle = angle_offset + x0, y0 = pos_start + x1 = x0 + 1000 * length * math.cos(math.radians(angle)) + y1 = y0 + 1000 * length * math.sin(math.radians(angle)) + origin_angle = math.degrees(math.atan2(y1 - y0, x1 - x0)) + pos_end = (x1, y1) + return pos_end, origin_angle + + # Make coordinates for neighbours of nodes of the longest path + # Nodes are placed in an angle of 90 degrees to the longest path + # with alternating direction + def coordinate_longest_path_neighbor(pos_start, angle_offset, length, direction): + length = length / branch_detour_factor + if direction: + angle_random_offset = 90 + else: + angle_random_offset = -90 + angle = angle_offset + angle_random_offset + x0, y0 = pos_start + x1 = x0 + 1000 * length * math.cos(math.radians(angle)) + y1 = y0 + 1000 * length * math.sin(math.radians(angle)) + origin_angle = math.degrees(math.atan2(y1 - y0, x1 - x0)) + pos_end = (x1, y1) + + return pos_end, origin_angle + + # Find transformer node and copy graph + start_node = list(nx.nodes(graph_root))[0] + graph_root.nodes[start_node]["pos"] = (0, 0) + graph_copy = graph_root.copy() + + long_paths = [] + next_nodes = [] + + # Find longest paths + for i in range(0, len(list(nx.neighbors(graph_root, start_node)))): + path_length_to_transformer = [] + for node in graph_copy.nodes(): + try: + paths = list(nx.shortest_simple_paths(graph_copy, start_node, node)) + except nx.NetworkXNoPath: + paths = [[]] + path_length_to_transformer.append(len(paths[0])) + index = path_length_to_transformer.index(max(path_length_to_transformer)) + path_to_max_distance_node = list( + nx.shortest_simple_paths( + graph_copy, start_node, list(nx.nodes(graph_copy))[index] + ) + )[0] + path_to_max_distance_node.remove(start_node) + graph_copy.remove_nodes_from(path_to_max_distance_node) + for node in path_to_max_distance_node: + long_paths.append(node) + + path_to_max_distance_node = long_paths + n = 0 + + # make the coordinates + for node in list(nx.neighbors(graph_root, start_node)): + n = n + 1 + pos, origin_angle = coordinate_source( + graph_root.nodes[start_node]["pos"], + graph_root.edges[start_node, node]["length"], + n, + len(list(nx.neighbors(graph_root, start_node))), + ) + graph_root.nodes[node]["pos"] = pos + graph_root.nodes[node]["origin_angle"] = origin_angle + next_nodes.append(node) + + graph_copy = graph_root.copy() + graph_copy.remove_node(start_node) + while graph_copy.number_of_nodes() > 0: + next_node = next_nodes[0] + n = 0 + for node in list(nx.neighbors(graph_copy, next_node)): + n = n + 1 + if node in path_to_max_distance_node: + pos, origin_angle = coordinate_longest_path( + graph_root.nodes[next_node]["pos"], + graph_root.nodes[next_node]["origin_angle"], + graph_root.edges[next_node, node]["length"], + ) + elif next_node in path_to_max_distance_node: + direction = math.fmod( + len( + list( + nx.shortest_simple_paths(graph_root, start_node, next_node) + )[0] + ), + 2, + ) + pos, origin_angle = coordinate_longest_path_neighbor( + graph_root.nodes[next_node]["pos"], + graph_root.nodes[next_node]["origin_angle"], + graph_root.edges[next_node, node]["length"], + direction, + ) + else: + pos, origin_angle = coordinate_branch( + graph_root.nodes[next_node]["pos"], + graph_root.nodes[next_node]["origin_angle"], + graph_root.edges[next_node, node]["length"], + n, + len(list(nx.neighbors(graph_copy, next_node))), + ) + + graph_root.nodes[node]["pos"] = pos + graph_root.nodes[node]["origin_angle"] = origin_angle + next_nodes.append(node) + + graph_copy.remove_node(next_node) + next_nodes.remove(next_node) + + return graph_root + + +def make_pseudo_coordinates_graph(G: Graph, branch_detour_factor: float) -> Graph: + """ + Generates pseudo coordinates for one graph. + + Parameters + ---------- + G : :networkx:`networkx.Graph` + Graph object to generate pseudo coordinates for. + + branch_detour_factor : float + Defines the quotient of the line length and the distance of the buses. + + Returns + ------- + :networkx:`networkx.Graph` + Graph with pseudo coordinates for all nodes. + + """ + start_time = time() + logger.debug("Start - Making pseudo coordinates for graph") + + x0, y0 = G.nodes[list(nx.nodes(G))[0]]["pos"] + G = _make_coordinates(G, branch_detour_factor) + x0, y0 = coor_transform.transform(x0, y0) + for node in G.nodes(): + x, y = G.nodes[node]["pos"] + G.nodes[node]["pos"] = coor_transform_back.transform(x + x0, y + y0) + + logger.debug("Finished in {}s".format(time() - start_time)) + return G + + +def make_pseudo_coordinates( + edisgo_root: EDisGo, mv_coordinates: bool = False +) -> EDisGo: + """ + Generates pseudo coordinates for all LV grids and optionally MV grid. + + Parameters + ---------- + edisgo_root : :class:`~.EDisGo` + eDisGo object + mv_coordinates : bool, optional + If True pseudo coordinates are also generated for MV grid. + Default: False. + + Returns + ------- + :class:`~.EDisGo` + eDisGo object with pseudo coordinates for all LV nodes and optionally MV nodes. + + """ + start_time = time() + logger.debug( + "Start - Making pseudo coordinates for grid: {}".format( + str(edisgo_root.topology.mv_grid) + ) + ) + + edisgo_obj = copy.deepcopy(edisgo_root) + + grids = list(edisgo_obj.topology.mv_grid.lv_grids) + if mv_coordinates: + grids = [edisgo_obj.topology.mv_grid] + grids + + for grid in grids: + logger.debug("Make pseudo coordinates for: {}".format(grid)) + G = grid.graph + G = make_pseudo_coordinates_graph( + G, edisgo_obj.config["grid_connection"]["branch_detour_factor"] + ) + for node in G.nodes(): + x, y = G.nodes[node]["pos"] + edisgo_obj.topology.buses_df.loc[node, "x"] = x + edisgo_obj.topology.buses_df.loc[node, "y"] = y + + logger.debug("Finished in {}s".format(time() - start_time)) + return edisgo_obj diff --git a/edisgo/tools/tools.py b/edisgo/tools/tools.py index 6f558fec7..6a063db73 100644 --- a/edisgo/tools/tools.py +++ b/edisgo/tools/tools.py @@ -1,3 +1,4 @@ +import logging import os from math import pi, sqrt @@ -20,6 +21,9 @@ from shapely.wkt import loads as wkt_loads +logger = logging.getLogger(__name__) + + def select_worstcase_snapshots(edisgo_obj): """ Select two worst-case snapshots from time series @@ -149,6 +153,28 @@ def calculate_line_resistance(line_resistance_per_km, line_length, num_parallel) return line_resistance_per_km * line_length / num_parallel +def calculate_line_susceptance(line_capacitance_per_km, line_length, num_parallel): + """ + Calculates line shunt susceptance in Siemens. + + Parameters + ---------- + line_capacitance_per_km : float + Line capacitance in uF/km. + line_length : float + Length of line in km. + num_parallel : int + Number of parallel lines. + + Returns + ------- + float + Shunt susceptance in Siemens. + + """ + return line_capacitance_per_km / 1e6 * line_length * 2 * pi * 50 * num_parallel + + def calculate_apparent_power(nominal_voltage, current, num_parallel): """ Calculates apparent power in MVA from given voltage and current. @@ -532,3 +558,76 @@ def get_files_recursive(path, files=None): files.append(file) return files + + +def add_line_susceptance( + edisgo_obj, + mode="mv_b", +): + """ + Adds line susceptance information in Siemens to lines in existing grids. + + Parameters + ---------- + edisgo_obj : :class:`~.EDisGo` + EDisGo object to which line susceptance information is added. + + mode : str + Defines how the susceptance is added: + + * 'no_b' + Susceptance is set to 0 for all lines. + * 'mv_b' (Default) + Susceptance is for the MV lines set according to the equipment parameters + and for the LV lines it is set to zero. + * 'all_b' + Susceptance is for the MV lines set according to the equipment parameters + and for the LV lines 0.25 uF/km is chosen. + + Returns + ------- + :class:`~.EDisGo` + + """ + line_data_df = pd.concat( + [ + edisgo_obj.topology.equipment_data["mv_overhead_lines"], + edisgo_obj.topology.equipment_data["mv_cables"], + edisgo_obj.topology.equipment_data["lv_cables"], + ] + ) + + if mode == "no_b": + line_data_df.loc[:, "C_per_km"] = 0 + elif mode == "mv_b": + line_data_df.loc[ + edisgo_obj.topology.equipment_data["lv_cables"].index, "C_per_km" + ] = 0 + elif mode == "all_b": + line_data_df.loc[ + edisgo_obj.topology.equipment_data["lv_cables"].index, "C_per_km" + ] = 0.25 + else: + raise ValueError("Non-existing mode.") + + lines_df = edisgo_obj.topology.lines_df + buses_df = edisgo_obj.topology.buses_df + + for index, bus0, type_info, length, num_parallel in lines_df[ + ["bus0", "type_info", "length", "num_parallel"] + ].itertuples(): + v_nom = buses_df.loc[bus0].v_nom + + try: + line_capacitance_per_km = ( + line_data_df.loc[line_data_df.U_n == v_nom].loc[type_info].C_per_km + ) + except KeyError: + line_capacitance_per_km = line_data_df.loc[type_info].C_per_km + logger.warning(f"False voltage level for line {index}.") + + lines_df.loc[index, "b"] = calculate_line_susceptance( + line_capacitance_per_km, length, num_parallel + ) + + return edisgo_obj diff --git a/examples/edisgo_simple_example.ipynb b/examples/edisgo_simple_example.ipynb index df5328960..99fcafb64 100755 --- a/examples/edisgo_simple_example.ipynb +++ b/examples/edisgo_simple_example.ipynb @@ -77,6 +77,29 @@ "from edisgo import EDisGo" ] }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "#### Set up logger" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# set up logger that streams edisgo logging messages with level info and above \n", + "# and other logging messages with level warning and above to stdout\n", + "setup_logger(\n", + " loggers=[\n", + " {\"name\": \"root\", \"file_level\": None, \"stream_level\": \"warning\"},\n", + " {\"name\": \"edisgo\", \"file_level\": None, \"stream_level\": \"info\"}\n", + " ]\n", + ")" + ] + }, { "cell_type": "markdown", "metadata": {}, diff --git a/examples/example_grid_reinforcement.py b/examples/example_grid_reinforcement.py index 803586a9d..ef671f515 100644 --- a/examples/example_grid_reinforcement.py +++ b/examples/example_grid_reinforcement.py @@ -32,19 +32,29 @@ from edisgo import EDisGo from edisgo.network.results import Results +from edisgo.tools.logger import setup_logger logger = logging.getLogger(__name__) -logger.setLevel(logging.DEBUG) def run_example(): + + # set up logger that streams edisgo logging messages with level info and above + # and other logging messages with level warning and above to stdout + setup_logger( + loggers=[ + {"name": "root", "file_level": None, "stream_level": "warning"}, + {"name": "edisgo", "file_level": None, "stream_level": "info"} + ] + ) + # Specify path to directory containing ding0 grid csv files edisgo_path = os.path.join(os.path.expanduser("~"), ".edisgo") dingo_grid_path = os.path.join(edisgo_path, "ding0_example_grid") # Download example grid data in case it does not yet exist - if not os.path.isdir(dingo_grid_path): + if not os.path.isdir(dingo_grid_path) or len(os.listdir(dingo_grid_path)) == 0: logger.debug("Download example grid data.") - os.makedirs(dingo_grid_path) + os.makedirs(dingo_grid_path, exist_ok=True) file_list = [ "buses.csv", "lines.csv", diff --git a/examples/plot_example.ipynb b/examples/plot_example.ipynb index 198302ec6..e8f95025b 100755 --- a/examples/plot_example.ipynb +++ b/examples/plot_example.ipynb @@ -35,6 +35,16 @@ "#### Import packages" ] }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "import logging\n", + "logging.basicConfig(level=logging.CRITICAL)" + ] + }, { "cell_type": "code", "execution_count": null, @@ -46,10 +56,11 @@ "import pandas as pd\n", "import requests\n", "import copy\n", + "import time\n", "\n", "from edisgo import EDisGo\n", - "from edisgo.tools.plots import draw_plotly\n", - "from edisgo.tools.plots import dash_plot" + "from edisgo.tools.plots import plot_plotly\n", + "from edisgo.tools.plots import plot_dash" ] }, { @@ -65,6 +76,8 @@ "metadata": {}, "outputs": [], "source": [ + "import requests\n", + "\n", "def download_ding0_example_grid():\n", "\n", " # create directories to save ding0 example grid into\n", @@ -100,30 +113,32 @@ ] }, { - "cell_type": "code", - "execution_count": null, + "cell_type": "markdown", "metadata": {}, - "outputs": [], "source": [ - "ding0_grid = os.path.join(os.path.expanduser(\"~\"), \".edisgo\", \"ding0_test_network\")" + "#### Create edisgo objects" ] }, { - "cell_type": "markdown", + "cell_type": "code", + "execution_count": null, "metadata": {}, + "outputs": [], "source": [ - "#### Create edisgo objects" + "ding0_grid = os.path.join(os.path.expanduser(\"~\"), \".edisgo\", \"ding0_test_network\")" ] }, { "cell_type": "code", "execution_count": null, - "metadata": {}, + "metadata": { + "scrolled": true, + "tags": [] + }, "outputs": [], "source": [ "edisgo_root = EDisGo(ding0_grid=ding0_grid)\n", - "edisgo_root.set_time_series_worst_case_analysis()\n", - "edisgo_root.analyze()" + "edisgo_root.set_time_series_worst_case_analysis()" ] }, { @@ -132,7 +147,8 @@ "metadata": {}, "outputs": [], "source": [ - "edisgo_copy = copy.deepcopy(edisgo_root)" + "edisgo_analyzed = copy.deepcopy(edisgo_root)\n", + "edisgo_analyzed.analyze()" ] }, { @@ -158,7 +174,7 @@ "tags": [] }, "source": [ - "### draw_plotly function" + "### plot_plotly function" ] }, { @@ -167,37 +183,46 @@ "tags": [] }, "source": [ - "In the following different plotting options are shown.\n", - "\n", - "The different options for node colors (set using parameter `mode_nodes`) and line colors (set using parameter `mode_lines`) are:\n", - "\n", - "- mode_nodes\n", - " - 'voltage_deviation' - shows the deviation of the node voltage relative to 1 p.u.\n", - " - 'adjacencies' - shows the the number of connections of the graph\n", - "- mode_lines\n", - " - 'relative_loading' - shows the line loading relative to the s_nom of the line\n", - " - 'loading' - shows the loading\n", - " - 'reinforce' - shows the reinforced lines in green\n", - "\n", - "\n", - "The different options for used coordinates (set using parameter `grid`) are:\n", + "In the following different plotting options are shown. For more information on different plotting options see [API docstring](https://edisgo.readthedocs.io/en/dev/api/edisgo.tools.html#edisgo.tools.plots.draw_plotly).\n", "\n", - "- grid\n", - " - grid object - the coordinate origin is set to the stations coordinates\n", - " - False - the coordinates are not modified \n", "\n", "Hovering over nodes and lines shows some information on them." ] }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "#### Plotting relative loading and voltage deviation, with grid coordinates modified to have the station in the origin" + ] + }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ - "edisgo_obj = edisgo_root\n", - "grid = edisgo_obj.topology.mv_grid\n", - "G = grid.graph" + "fig = plot_plotly(\n", + " edisgo_obj=edisgo_analyzed,\n", + " grid=None,\n", + " line_color=\"relative_loading\",\n", + " node_color=\"voltage_deviation\",\n", + " line_result_selection=\"max\",\n", + " node_result_selection=\"max\",\n", + " selected_timesteps=None,\n", + " center_coordinates=True,\n", + " pseudo_coordinates=False,\n", + " node_selection=False\n", + ")\n", + "\n", + "fig.show()" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "#### Plotting relative loading and voltage deviation, with time step selection" ] }, { @@ -206,14 +231,28 @@ "metadata": {}, "outputs": [], "source": [ - "# plotting relative loading and voltage deviation, with grid coordinates modified to have the station in the origin\n", - "mode_lines = \"relative_loading\"\n", - "mode_nodes = \"voltage_deviation\"\n", - "\n", - "fig = draw_plotly(\n", - " edisgo_obj, G, line_color=mode_lines, node_color=mode_nodes, grid=grid\n", - ")\n", - "fig.show()" + "plot_plotly(\n", + " edisgo_obj=edisgo_analyzed,\n", + " grid=None,\n", + " line_color=\"relative_loading\",\n", + " node_color=\"voltage_deviation\",\n", + " line_result_selection=\"max\",\n", + " node_result_selection=\"max\",\n", + " selected_timesteps=[\n", + " '1970-01-01 00:00:00', \n", + " '1970-01-01 01:00:00',\n", + " ],\n", + " center_coordinates=False,\n", + " pseudo_coordinates=False,\n", + " node_selection=False\n", + ")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "#### Plotting loading and voltage deviation, with pseudo coordinates" ] }, { @@ -222,14 +261,25 @@ "metadata": {}, "outputs": [], "source": [ - "# plotting loading and voltage deviation, with unchanged coordinates\n", - "mode_lines = \"loading\"\n", - "mode_nodes = \"voltage_deviation\"\n", - "\n", - "fig = draw_plotly(\n", - " edisgo_obj, G, line_color=mode_lines, node_color=mode_nodes, grid=False\n", - ")\n", - "fig.show()" + "plot_plotly(\n", + " edisgo_obj=edisgo_analyzed,\n", + " grid=None,\n", + " line_color=\"loading\",\n", + " node_color=\"voltage_deviation\",\n", + " line_result_selection=\"max\",\n", + " node_result_selection=\"max\",\n", + " selected_timesteps='1970-01-01 03:00:00',\n", + " center_coordinates=True,\n", + " pseudo_coordinates=True,\n", + " node_selection=False\n", + ")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "#### Plotting reinforced lines, node adjacencies and a node selection" ] }, { @@ -238,17 +288,26 @@ "metadata": {}, "outputs": [], "source": [ - "# plotting reinforced lines and node adjacencies\n", - "edisgo_obj = edisgo_reinforced\n", - "G = edisgo_obj.topology.mv_grid.graph\n", - "\n", - "mode_lines = \"reinforce\"\n", - "mode_nodes = \"adjacencies\"\n", - "\n", - "fig = draw_plotly(\n", - " edisgo_obj, G, line_color=mode_lines, node_color=mode_nodes, grid=False\n", - ")\n", - "fig.show()" + "plot_plotly(\n", + " edisgo_obj=edisgo_reinforced,\n", + " grid=None,\n", + " line_color=\"reinforce\",\n", + " node_color=\"adjacencies\",\n", + " line_result_selection=\"max\",\n", + " node_result_selection=\"max\",\n", + " selected_timesteps=None,\n", + " center_coordinates=True,\n", + " pseudo_coordinates=False,\n", + " node_selection=[\n", + " 'Bus_MVStation_1',\n", + " 'Bus_BranchTee_MVGrid_1_5',\n", + " 'Bus_BranchTee_MVGrid_1_10',\n", + " 'Bus_GeneratorFluctuating_4',\n", + " 'Bus_BranchTee_MVGrid_1_11',\n", + " 'Bus_GeneratorFluctuating_3',\n", + " 'BusBar_MVGrid_1_LVGrid_4_MV'\n", + " ]\n", + ")" ] }, { @@ -257,7 +316,7 @@ "tags": [] }, "source": [ - "### Dash plot app which calls draw_plotly\n", + "### Dash plot app which calls plot_plotly\n", "One edisgo object creates one large plot. Two or more edisgo objects create two adjacent plots, where the objects to be plotted are selected in the dropdown menu." ] }, @@ -273,11 +332,13 @@ { "cell_type": "code", "execution_count": null, - "metadata": {}, + "metadata": { + "scrolled": true + }, "outputs": [], "source": [ - "app = dash_plot(edisgo_objects=edisgo_root)\n", - "app.run_server(mode=\"inline\")" + "plot_dash(edisgo_objects=edisgo_analyzed)\n", + "time.sleep(2)" ] }, { @@ -293,10 +354,8 @@ "metadata": {}, "outputs": [], "source": [ - "app = dash_plot(\n", - " edisgo_objects={\"edisgo_obj_1\": edisgo_root, \"edisgo_obj_2\": edisgo_reinforced}\n", - ")\n", - "app.run_server(mode=\"inline\")" + "plot_dash(edisgo_objects={\"edisgo_analyzed\": edisgo_analyzed, \"edisgo_reinforced\": edisgo_reinforced})\n", + "time.sleep(1)" ] }, { @@ -314,22 +373,14 @@ "metadata": {}, "outputs": [], "source": [ - "app = dash_plot(\n", + "plot_dash(\n", " edisgo_objects={\n", - " \"edisgo_obj_1\": edisgo_root,\n", - " \"edisgo_obj_2\": edisgo_reinforced,\n", - " \"edisgo_obj_3\": edisgo_copy,\n", + " \"edisgo_root\": edisgo_root,\n", + " \"edisgo_analyzed\": edisgo_analyzed,\n", + " \"edisgo_reinforced\": edisgo_reinforced\n", " }\n", - ")\n", - "app.run_server(mode=\"inline\")" + ")" ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [] } ], "metadata": { diff --git a/setup.py b/setup.py index dd0fd7221..40052998d 100644 --- a/setup.py +++ b/setup.py @@ -56,16 +56,16 @@ def read(fname): "contextily", "descartes", "plotly", - "dash==2.0.0", - "werkzeug==2.0.3", + "dash==2.6.0", + "werkzeug==2.2.0", ] examples_requirements = [ "jupyter", "jupyterlab", "plotly", - "dash==2.0.0", + "dash==2.6.0", "jupyter_dash", - "werkzeug==2.0.3", + "werkzeug==2.2.0", ] dev_requirements = [ "pytest", diff --git a/tests/data/ding0_test_network_1/lines.csv b/tests/data/ding0_test_network_1/lines.csv index 02bd0b4c9..de0ae0b27 100755 --- a/tests/data/ding0_test_network_1/lines.csv +++ b/tests/data/ding0_test_network_1/lines.csv @@ -2,8 +2,8 @@ name,bus0,bus1,length,x,r,s_nom,num_parallel,kind,type_info Line_10003,Bus_MVStation_1,Bus_BranchTee_MVGrid_1_1,0.083904310632546,0.031103993574751,0.031044594934042,7.27461339178928,1,line,48-AL1/8-ST1A Line_10004,Bus_MVStation_1,Bus_BranchTee_MVGrid_1_4,0.226148217025158,0.083834938112983,0.083674840299309,7.27461339178928,1,line,48-AL1/8-ST1A Line_10005,Bus_MVStation_1,Bus_BranchTee_MVGrid_1_5,0.543380191002877,0.201435347506981,0.201050670671064,7.27461339178928,1,line,48-AL1/8-ST1A -Line_10006,Bus_MVStation_1,Bus_BranchTee_MVGrid_1_6,0.297650465459542,0.110341388843783,0.110130672220031,7.27461339178928,1,line,NA2XS2Y 3x1x185 RM/25 -Line_10007,Bus_BranchTee_MVGrid_1_1,BusBar_MVGrid_1_LVGrid_1_MV,0.722445826838636,0.267816399261118,0.267304955930295,7.27461339178928,1,line,NA2XS2Y 3x1x185 RM/25 +Line_10006,Bus_MVStation_1,Bus_BranchTee_MVGrid_1_6,0.297650465459542,0.03363542166704131,0.03869456050974046,7.27461339178928,1,line,NA2XS2Y 3x1x240 +Line_10007,Bus_BranchTee_MVGrid_1_1,BusBar_MVGrid_1_LVGrid_1_MV,0.722445826838636,0.08163860916459667,0.09391795748902268,7.27461339178928,1,line,NA2XS2Y 3x1x240 Line_10008,Bus_BranchTee_MVGrid_1_2,BusBar_MVGrid_1_LVGrid_1_MV,0.132867031507129,0.049254862630276,0.049160801657638,7.27461339178928,1,line,48-AL1/8-ST1A Line_10009,Bus_BranchTee_MVGrid_1_2,BusBar_MVGrid_1_LVGrid_2_MV,0.705785336662554,0.261640223383117,0.261140574565145,7.27461339178928,1,line,48-AL1/8-ST1A Line_10010,Bus_BranchTee_MVGrid_1_2,BusBar_MVGrid_1_LVGrid_5_MV,0.012061682815469,0.004471361506515,0.004462822641724,7.27461339178928,1,line,48-AL1/8-ST1A diff --git a/tests/flex_opt/test_costs.py b/tests/flex_opt/test_costs.py index f5dea1b0a..6864ef461 100644 --- a/tests/flex_opt/test_costs.py +++ b/tests/flex_opt/test_costs.py @@ -78,7 +78,7 @@ def test_costs(self): costs = grid_expansion_costs(self.edisgo) - assert len(costs) == 5 + assert len(costs) == 4 assert ( costs.loc["MVStation_1_transformer_reinforced_2", "voltage_level"] == "mv/lv" @@ -91,11 +91,6 @@ def test_costs(self): ) assert costs.loc["LVStation_1_transformer_reinforced_1", "quantity"] == 1 assert costs.loc["LVStation_1_transformer_reinforced_1", "total_costs"] == 10 - assert np.isclose(costs.loc["Line_10006", "total_costs"], 29.765) - assert np.isclose(costs.loc["Line_10006", "length"], (0.29765 * 2)) - assert costs.loc["Line_10006", "quantity"] == 2 - assert costs.loc["Line_10006", "type"] == "NA2XS2Y 3x1x185 RM/25" - assert costs.loc["Line_10006", "voltage_level"] == "mv" assert np.isclose(costs.loc["Line_10019", "total_costs"], 32.3082) assert np.isclose(costs.loc["Line_10019", "length"], 0.40385) assert costs.loc["Line_10019", "quantity"] == 1 diff --git a/tests/flex_opt/test_reinforce_grid.py b/tests/flex_opt/test_reinforce_grid.py index 461655ecf..0f6c79c70 100644 --- a/tests/flex_opt/test_reinforce_grid.py +++ b/tests/flex_opt/test_reinforce_grid.py @@ -30,8 +30,10 @@ def test_reinforce_grid(self): target = ["mv"] elif mode == "mvlv": target = ["mv", "mv/lv"] - else: + elif mode == "lv": target = ["mv/lv", "lv"] + else: + raise ValueError("Non existing mode") assert_array_equal( np.sort(target), diff --git a/tests/flex_opt/test_reinforce_measures.py b/tests/flex_opt/test_reinforce_measures.py index c7f14bf24..dc3e93dd9 100644 --- a/tests/flex_opt/test_reinforce_measures.py +++ b/tests/flex_opt/test_reinforce_measures.py @@ -1,3 +1,5 @@ +import copy + import numpy as np import pandas as pd import pytest @@ -13,6 +15,7 @@ def setup_class(cls): cls.edisgo.set_time_series_worst_case_analysis() cls.edisgo.analyze() + cls.edisgo_root = copy.deepcopy(cls.edisgo) cls.timesteps = pd.date_range("1/1/1970", periods=2, freq="H") def test_reinforce_mv_lv_station_overloading(self): @@ -21,6 +24,8 @@ def test_reinforce_mv_lv_station_overloading(self): # create problems such that in LVGrid_1 existing transformer is # exchanged with standard transformer and in LVGrid_4 a third # transformer is added + self.edisgo = copy.deepcopy(self.edisgo_root) + crit_lv_stations = pd.DataFrame( { "s_missing": [0.17, 0.04], @@ -85,6 +90,8 @@ def test_reinforce_hv_mv_station_overloading(self): # implicitly checks function _station_overloading # check adding transformer of same MVA + self.edisgo = copy.deepcopy(self.edisgo_root) + crit_mv_station = pd.DataFrame( {"s_missing": [19], "time_index": [self.timesteps[1]]}, index=["MVGrid_1"], @@ -139,6 +146,8 @@ def test_reinforce_hv_mv_station_overloading(self): ) def test_reinforce_mv_lv_station_voltage_issues(self): + self.edisgo = copy.deepcopy(self.edisgo_root) + station_9 = pd.DataFrame( {"v_diff_max": [0.03], "time_index": [self.timesteps[0]]}, index=["Bus_secondary_LVStation_9"], @@ -187,6 +196,7 @@ def test_reinforce_lines_voltage_issues(self): # * check problem in same feeder => Bus_BranchTee_MVGrid_1_10 (node # has higher voltage issue than Bus_BranchTee_MVGrid_1_11, but # Bus_BranchTee_MVGrid_1_10 is farther away from station) + self.edisgo = copy.deepcopy(self.edisgo_root) crit_nodes = pd.DataFrame( { @@ -244,7 +254,7 @@ def test_reinforce_lines_voltage_issues(self): ) # check line parameters std_line_mv = self.edisgo.topology.equipment_data["mv_cables"].loc[ - self.edisgo.config["grid_expansion_standard_equipment"]["mv_line"] + self.edisgo.config["grid_expansion_standard_equipment"]["mv_line_20kv"] ] line = self.edisgo.topology.lines_df.loc["Line_10028"] assert line.type_info == std_line_mv.name @@ -353,6 +363,7 @@ def test_reinforce_lines_overloading(self): # and Line_50000002 # * check for replacement by parallel standard lines (MV and LV) => # problems at Line_10003 and Line_60000001 + self.edisgo = copy.deepcopy(self.edisgo_root) # create crit_lines dataframe crit_lines = pd.DataFrame( @@ -385,10 +396,10 @@ def test_reinforce_lines_overloading(self): # check lines that were already standard lines and where parallel # standard lines were added line = self.edisgo.topology.lines_df.loc["Line_10007"] - assert line.type_info == "NA2XS2Y 3x1x185 RM/25" - assert np.isclose(line.r, 0.164 * line.length / 3) - assert np.isclose(line.x, 0.38 * 2 * np.pi * 50 / 1e3 * line.length / 3) - assert np.isclose(line.s_nom, 0.357 * 20 * np.sqrt(3) * 3) + assert line.type_info == "NA2XS2Y 3x1x240" + assert np.isclose(line.r, 0.13 * line.length / 3) + assert np.isclose(line.x, 0.3597 * 2 * np.pi * 50 / 1e3 * line.length / 3) + assert np.isclose(line.s_nom, 7.27461339178928 * 3) assert line.num_parallel == 3 line = self.edisgo.topology.lines_df.loc["Line_70000006"] @@ -415,10 +426,10 @@ def test_reinforce_lines_overloading(self): # check lines that were exchanged by parallel standard lines line = self.edisgo.topology.lines_df.loc["Line_10003"] - assert line.type_info == "NA2XS2Y 3x1x185 RM/25" - assert np.isclose(line.r, 0.164 * line.length / 2) - assert np.isclose(line.x, 0.38 * 2 * np.pi * 50 / 1e3 * line.length / 2) - assert np.isclose(line.s_nom, 0.357 * 20 * np.sqrt(3) * 2) + assert line.type_info == "NA2XS2Y 3x1x240" + assert np.isclose(line.r, 0.13 * line.length / 2) + assert np.isclose(line.x, 0.3597 * 2 * np.pi * 50 / 1e3 * line.length / 2) + assert np.isclose(line.s_nom, 0.417 * 20 * np.sqrt(3) * 2) assert line.num_parallel == 2 line = self.edisgo.topology.lines_df.loc["Line_60000001"] diff --git a/tests/network/test_timeseries.py b/tests/network/test_timeseries.py index 1554496a2..9e276e1a6 100644 --- a/tests/network/test_timeseries.py +++ b/tests/network/test_timeseries.py @@ -1393,6 +1393,7 @@ def test_predefined_fluctuating_generators_by_technology(self): # time series for generators are set for those for which time series are # provided) self.edisgo.timeseries.reset() + self.edisgo.timeseries.timeindex = timeindex self.edisgo.timeseries.predefined_fluctuating_generators_by_technology( self.edisgo, gens_p ) @@ -2310,6 +2311,71 @@ def test_check_if_components_exist(self): assert len(component_names) == 1 assert "Load_residential_LVGrid_5_3" in component_names + def test_resample_timeseries(self): + + self.edisgo.set_time_series_worst_case_analysis() + + len_timeindex_orig = len(self.edisgo.timeseries.timeindex) + mean_value_orig = self.edisgo.timeseries.generators_active_power.mean() + + # test up-sampling + self.edisgo.timeseries.resample_timeseries() + # check if resampled length of time index is 4 times original length of + # timeindex + assert len(self.edisgo.timeseries.timeindex) == 4 * len_timeindex_orig + # check if mean value of resampled data is the same as mean value of original + # data + assert ( + np.isclose( + self.edisgo.timeseries.generators_active_power.mean(), + mean_value_orig, + atol=1e-5, + ) + ).all() + + # same tests for down-sampling + self.edisgo.timeseries.resample_timeseries(freq="2h") + assert len(self.edisgo.timeseries.timeindex) == 0.5 * len_timeindex_orig + assert ( + np.isclose( + self.edisgo.timeseries.generators_active_power.mean(), + mean_value_orig, + atol=1e-5, + ) + ).all() + + # test bfill + self.edisgo.timeseries.resample_timeseries(method="bfill") + assert len(self.edisgo.timeseries.timeindex) == 4 * len_timeindex_orig + assert np.isclose( + self.edisgo.timeseries.generators_active_power.iloc[1:, :].loc[ + :, "GeneratorFluctuating_3" + ], + 2.26950, + atol=1e-5, + ).all() + + # test interpolate + self.edisgo.timeseries.reset() + self.edisgo.set_time_series_worst_case_analysis() + len_timeindex_orig = len(self.edisgo.timeseries.timeindex) + ts_orig = self.edisgo.timeseries.generators_active_power.loc[ + :, "GeneratorFluctuating_3" + ] + self.edisgo.timeseries.resample_timeseries(method="interpolate") + assert len(self.edisgo.timeseries.timeindex) == 4 * len_timeindex_orig + assert np.isclose( + self.edisgo.timeseries.generators_active_power.at[ + pd.Timestamp("1970-01-01 01:30:00"), "GeneratorFluctuating_3" + ], + ( + ts_orig.at[pd.Timestamp("1970-01-01 01:00:00")] + + ts_orig.at[pd.Timestamp("1970-01-01 02:00:00")] + ) + / 2, + atol=1e-5, + ) + class TestTimeSeriesRaw: @pytest.fixture(autouse=True) diff --git a/tests/network/test_topology.py b/tests/network/test_topology.py index 51431ac21..f3c161b71 100644 --- a/tests/network/test_topology.py +++ b/tests/network/test_topology.py @@ -419,7 +419,7 @@ def test_add_line(self): bus1 = "Bus_BranchTee_LVGrid_1_10" msg = ( "When line 'type_info' is provided when creating a new " - "line, x, r and s_nom are calculated and provided " + "line, x, r, b and s_nom are calculated and provided " "parameters are overwritten." ) with pytest.warns(UserWarning, match=msg): diff --git a/tests/tools/test_logger.py b/tests/tools/test_logger.py new file mode 100644 index 000000000..f461c891b --- /dev/null +++ b/tests/tools/test_logger.py @@ -0,0 +1,59 @@ +import logging +import os + +from edisgo.tools.logger import setup_logger + + +class TestClass: + def test_setup_logger(self): + def check_file_output(output): + with open("edisgo.log", "r") as file: + last_line = file.readlines()[-1].split(" ")[3:] + last_line = " ".join(last_line) + assert last_line == output + + def reset_loggers(): + logger = logging.getLogger("edisgo") + logger.propagate = True + logger.handlers.clear() + logger = logging.getLogger() + logger.handlers.clear() + + if os.path.exists("edisgo.log"): + os.remove("edisgo.log") + + setup_logger( + loggers=[ + {"name": "root", "file_level": "debug", "stream_level": "debug"}, + {"name": "edisgo", "file_level": "debug", "stream_level": "debug"}, + ], + file_name="edisgo.log", + ) + + logger = logging.getLogger("edisgo") + # Test that edisgo logger writes to file. + logger.debug("root") + check_file_output("edisgo - DEBUG: root\n") + # Test that root logger writes to file. + logging.debug("root") + check_file_output("root - DEBUG: root\n") + + # reset_loggers() + + setup_logger( + loggers=[ + {"name": "edisgo", "file_level": "debug", "stream_level": "debug"}, + ], + file_name="edisgo.log", + reset_loggers=True, + debug_message=True, + ) + logger = logging.getLogger("edisgo") + # Test that edisgo logger writes to file. + logger.debug("edisgo") + check_file_output("edisgo - DEBUG: edisgo\n") + # Test that root logger doesn't writes to file. + logging.debug("edisgo") + check_file_output("edisgo - DEBUG: edisgo\n") + + os.remove("edisgo.log") diff --git a/tests/tools/test_plots.py b/tests/tools/test_plots.py index 0e385d2e0..c8d855ce8 100644 --- a/tests/tools/test_plots.py +++ b/tests/tools/test_plots.py @@ -1,27 +1,120 @@ +import copy + import pytest from edisgo import EDisGo -from edisgo.tools.plots import dash_plot +from edisgo.tools.plots import chosen_graph, plot_dash_app, plot_plotly class TestPlots: @classmethod def setup_class(cls): - cls.edisgo = EDisGo(ding0_grid=pytest.ding0_test_network_path) - cls.edisgo.set_time_series_worst_case_analysis() - cls.edisgo.reinforce() + cls.edisgo_root = EDisGo(ding0_grid=pytest.ding0_test_network_path) + cls.edisgo_root.set_time_series_worst_case_analysis() + cls.edisgo_analyzed = copy.deepcopy(cls.edisgo_root) + cls.edisgo_reinforced = copy.deepcopy(cls.edisgo_root) + cls.edisgo_analyzed.analyze() + cls.edisgo_reinforced.reinforce() + cls.edisgo_reinforced.results.equipment_changes.loc[ + "Line_10006", "change" + ] = "added" + + @pytest.mark.parametrize( + "line_color, node_color, line_result_selection, node_result_selection" + ", center_coordinates, pseudo_coordinates", + [ + ("loading", "voltage_deviation", "min", "min", True, True), + ("relative_loading", "adjacencies", "max", "max", False, False), + ("reinforce", "adjacencies", "max", "min", True, False), + ], + ) + @pytest.mark.parametrize( + "selected_timesteps", + [ + None, + "1970-01-01 01:00:00", + ["1970-01-01 01:00:00", "1970-01-01 03:00:00"], + ], + ) + @pytest.mark.parametrize( + "node_selection", [False, ["Bus_MVStation_1", "Bus_BranchTee_MVGrid_1_5"]] + ) + @pytest.mark.parametrize( + "edisgo_obj_name", ["edisgo_root", "edisgo_analyzed", "edisgo_reinforced"] + ) + @pytest.mark.parametrize("grid_name", ["None", "LVGrid"]) + def test_plot_plotly( + self, + edisgo_obj_name, + grid_name, + line_color, + node_color, + line_result_selection, + node_result_selection, + selected_timesteps, + center_coordinates, + pseudo_coordinates, + node_selection, + ): + + if edisgo_obj_name == "edisgo_root": + edisgo_obj = self.edisgo_root + elif edisgo_obj_name == "edisgo_analyzed": + edisgo_obj = self.edisgo_analyzed + elif edisgo_obj_name == "edisgo_reinforced": + edisgo_obj = self.edisgo_reinforced + + if grid_name == "None": + grid = None + elif grid_name == "LVGrid": + grid = list(edisgo_obj.topology.mv_grid.lv_grids)[1] + + if (grid_name == "LVGrid") and (node_selection is not False): + with pytest.raises(ValueError): + plot_plotly( + edisgo_obj=edisgo_obj, + grid=grid, + line_color=line_color, + node_color=node_color, + line_result_selection=line_result_selection, + node_result_selection=node_result_selection, + selected_timesteps=selected_timesteps, + center_coordinates=center_coordinates, + pseudo_coordinates=pseudo_coordinates, + node_selection=node_selection, + ) + else: + plot_plotly( + edisgo_obj=edisgo_obj, + grid=grid, + line_color=line_color, + node_color=node_color, + line_result_selection=line_result_selection, + node_result_selection=node_result_selection, + selected_timesteps=selected_timesteps, + center_coordinates=center_coordinates, + pseudo_coordinates=pseudo_coordinates, + node_selection=node_selection, + ) + + def test_chosen_graph(self): + chosen_graph(edisgo_obj=self.edisgo_root, selected_grid="Grid") + grid = str(self.edisgo_root.topology.mv_grid) + chosen_graph(edisgo_obj=self.edisgo_root, selected_grid=grid) + grid = list(map(str, self.edisgo_root.topology.mv_grid.lv_grids))[0] + chosen_graph(edisgo_obj=self.edisgo_root, selected_grid=grid) - def test_dash_plot(self): + def test_plot_dash_app(self): # TODO: at the moment this doesn't really test anything. Add meaningful tests. # test if any errors occur when only passing one edisgo object - app = dash_plot( - edisgo_objects=self.edisgo, + plot_dash_app( + edisgo_objects=self.edisgo_root, ) # test if any errors occur when passing multiple edisgo objects - app = dash_plot( # noqa: F841 + plot_dash_app( # noqa: F841 edisgo_objects={ - "edisgo_1": self.edisgo, - "edisgo_2": self.edisgo, + "edisgo_1": self.edisgo_root, + "edisgo_2": self.edisgo_reinforced, } ) diff --git a/tests/tools/test_pseudo_coordinates.py b/tests/tools/test_pseudo_coordinates.py new file mode 100644 index 000000000..4ada42f05 --- /dev/null +++ b/tests/tools/test_pseudo_coordinates.py @@ -0,0 +1,34 @@ +import numpy as np +import pytest + +from edisgo import EDisGo +from edisgo.tools.pseudo_coordinates import make_pseudo_coordinates + + +class TestPseudoCoordinates: + @classmethod + def setup_class(cls): + cls.edisgo_root = EDisGo(ding0_grid=pytest.ding0_test_network_path) + + def test_make_pseudo_coordinates(self): + # make pseudo coordinates + edisgo_pseudo_coordinates = make_pseudo_coordinates( + edisgo_root=self.edisgo_root, mv_coordinates=True + ) + + # test that coordinates change for one node + coordinates = self.edisgo_root.topology.buses_df.loc[ + "Bus_BranchTee_LVGrid_1_9", ["x", "y"] + ] + assert round(coordinates[0], 5) != round(7.943307, 5) + assert round(coordinates[1], 5) != round(48.080396, 5) + + # test if the right coordinates are set for one node + coordinates = edisgo_pseudo_coordinates.topology.buses_df.loc[ + "Bus_BranchTee_LVGrid_1_9", ["x", "y"] + ] + assert round(coordinates[0], 5) == round(7.943307, 5) + assert round(coordinates[1], 5) == round(48.080396, 5) + + assert self.edisgo_root.topology.buses_df.x.isin([np.NaN]).any() + assert not edisgo_pseudo_coordinates.topology.buses_df.x.isin([np.NaN]).any() diff --git a/tests/tools/test_tools.py b/tests/tools/test_tools.py index 367d6bf8c..3012311fc 100644 --- a/tests/tools/test_tools.py +++ b/tests/tools/test_tools.py @@ -1,3 +1,5 @@ +import copy + from math import sqrt import numpy as np @@ -73,6 +75,14 @@ def test_calculate_line_resistance(self): data = tools.calculate_line_resistance(np.array([2, 3]), 3, 2) assert_array_equal(data, np.array([3, 4.5])) + def test_calculate_line_susceptance(self): + # test single line + assert np.isclose(tools.calculate_line_susceptance(2, 3, 1), 0.00188495559) + # test parallel line + assert np.isclose(tools.calculate_line_susceptance(2, 3, 2), 2 * 0.00188495559) + # test line with c = 0 + assert np.isclose(tools.calculate_line_susceptance(0, 3, 1), 0) + def test_calculate_apparent_power(self): # test single line data = tools.calculate_apparent_power(20, 30, 1) @@ -238,3 +248,35 @@ def test_get_weather_cells_intersecting_with_grid_district(self): # but there are generators in the grid that have that weather cell # for some reason.. assert 1122074 in weather_cells + + def test_add_susceptance(self): + assert self.edisgo.topology.lines_df.loc["Line_10006", "b"] == 0 + assert self.edisgo.topology.lines_df.loc["Line_50000002", "b"] == 0 + + # test mode no_b + edisgo_root = copy.deepcopy(self.edisgo) + edisgo_root.topology.lines_df.loc["Line_10006", "b"] = 1 + edisgo_root.topology.lines_df.loc["Line_50000002", "b"] = 1 + edisgo_root = tools.add_line_susceptance(edisgo_root, mode="no_b") + assert edisgo_root.topology.lines_df.loc["Line_10006", "b"] == 0 + assert edisgo_root.topology.lines_df.loc["Line_50000002", "b"] == 0 + + # test mode mv_b + edisgo_root = copy.deepcopy(self.edisgo) + edisgo_root.topology.lines_df.loc["Line_10006", "b"] = 1 + edisgo_root.topology.lines_df.loc["Line_50000002", "b"] = 1 + edisgo_root = tools.add_line_susceptance(edisgo_root, mode="mv_b") + assert edisgo_root.topology.lines_df.loc[ + "Line_10006", "b" + ] == tools.calculate_line_susceptance(0.304, 0.297650465459542, 1) + assert edisgo_root.topology.lines_df.loc["Line_50000002", "b"] == 0 + + # test mode all_b + edisgo_root = copy.deepcopy(self.edisgo) + edisgo_root = tools.add_line_susceptance(edisgo_root, mode="all_b") + assert edisgo_root.topology.lines_df.loc[ + "Line_10006", "b" + ] == tools.calculate_line_susceptance(0.304, 0.297650465459542, 1) + assert edisgo_root.topology.lines_df.loc[ + "Line_50000002", "b" + ] == tools.calculate_line_susceptance(0.25, 0.03, 1)