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 7a35d419d..d2943fa91 100644 --- a/edisgo/flex_opt/reinforce_measures.py +++ b/edisgo/flex_opt/reinforce_measures.py @@ -9,6 +9,7 @@ _dijkstra as dijkstra_shortest_path_length, ) +from edisgo.network.components import Switch from edisgo.network.grids import LVGrid, MVGrid from edisgo.tools import geo @@ -399,7 +400,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.") @@ -694,41 +695,54 @@ 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 @@ -1226,3 +1240,349 @@ def get_weight(u, v, data): top_edisgo.lines_df.loc[ top_edisgo.lines_df.bus0 == bus0, "bus0" ] = new_lv_busbar + + +def optimize_cb_location(edisgo_obj, mv_grid, mode="loadgen"): + """ + Locates the circuit breakers at the optimal position in the rings to + reduce the difference in loading of feeders + + Parameters + ---------- + edisgo_obj: + class:`~.EDisGo` + mv_grid : + class:`~.network.grids.MVGrid` + mode :obj:`str` + Type of loading. + 1-'load' + 2-'loadgen' + 3-'gen' + Default: 'loadgen'. + + + Notes:According to planning principles of MV grids, a MV ring is run as two strings + (half-rings) separated by a circuit breaker which is open at normal operation. + Assuming a ring (route which is connected to the root node at either sides), + the optimal position of a circuit breaker is defined as the position + (virtual cable) between two nodes where the conveyed current is minimal on the + route.Instead of the peak current,the peak load is used here (assuming a constant + voltage. + + The circuit breaker will be installed to a node in the main route of the ring + + If a ring is dominated by loads (peak load > peak capacity of generators), + only loads are used for determining the location of circuit breaker. + If generators are prevailing (peak load < peak capacity of generators), + only generator capacities are considered for relocation. + + Returns + ------- + obj:`str` + the node where the cb is located + + """ + logging.basicConfig(format=10) + # power factor of loads and generators + cos_phi_load = edisgo_obj.config["reactive_power_factor"]["mv_load"] + cos_phi_feedin = edisgo_obj.config["reactive_power_factor"]["mv_gen"] + + buses_df = edisgo_obj.topology.buses_df + lines_df = edisgo_obj.topology.lines_df + loads_df = edisgo_obj.topology.loads_df + generators_df = edisgo_obj.topology.generators_df + switches_df = edisgo_obj.topology.switches_df + transformers_df = edisgo_obj.topology.transformers_df + + station = mv_grid.station.index[0] + graph = mv_grid.graph + + def id_mv_node(mv_node): + """ + Returns id of mv node + Parameters + ---------- + mv_node:'str' + name of node. E.g. 'BusBar_mvgd_2534_lvgd_450268_MV' + + Returns + ------- + obj:`str` + the id of the node. E.g '450268' + """ + lv_bus_tranformer = transformers_df[transformers_df.bus0 == mv_node].bus1[0] + lv_id = buses_df[buses_df.index == lv_bus_tranformer].lv_grid_id[0] + return int(lv_id) + + def _sort_rings(remove_mv_station=True): + """ + Sorts the nodes beginning from HV/MV station in the ring. + + Parameters + ---------- + remove_mv_station : + obj:`boolean` + If True reinforcement HV/MV station is not included + Default: True. + + Returns + ------- + obj:'dict` + Dictionary with name of sorted nodes in the ring + """ + # close switches + switches = [ + Switch(id=_, topology=edisgo_obj.topology) + for _ in edisgo_obj.topology.switches_df.index + ] + switch_status = {} + for switch in switches: + switch_status[switch] = switch.state + switch.close() + # find rings in topology + graph = edisgo_obj.topology.to_graph() + rings = nx.cycle_basis(graph, root=station) + if remove_mv_station: + + for r in rings: + r.remove(station) + + # reopen switches + for switch in switches: + if switch_status[switch] == "open": + switch.open() + return rings + + def get_subtree_of_nodes(ring, graph): + """ + Finds all nodes of a tree that is connected to main nodes in the ring and are + (except main nodes) not part of the ring of main nodes (traversal of graph + from main nodes excluding nodes along ring). + Parameters + ---------- + edisgo_obj: + class:`~.EDisGo` + ring: + obj:'dict` + Dictionary with name of sorted nodes in the ring + graph + networkx:`networkx.Graph` + + Returns + ------- + obj:'dict` + index:main node + columns: nodes of main node's tree + """ + node_ring_d = {} + for node in ring: + + if node == station: + continue + + nodes_subtree = set() + for path in nx.shortest_path(graph, node).values(): + if len(path) > 1: + if (path[1] not in ring) and (path[1] != station): + nodes_subtree.update(path[1 : len(path)]) + + if len(nodes_subtree) == 0: + node_ring_d.setdefault(node, []).append(None) + else: + for node_subtree in nodes_subtree: + node_ring_d.setdefault(node, []).append(node_subtree) + + return node_ring_d + + def _calculate_peak_load_gen(bus_node): + """ + Cumulative peak load/generation of loads/generators connected to underlying + MV or LV grid + Parameters + ---------- + bus_node: + obj: bus_name of the node. + + Returns + ------- + obj:'list' + list of total generation and load of MV node + """ + if ( + bus_node + in buses_df[ + buses_df.index.str.contains("BusBar") + & (~buses_df.index.str.contains("virtual")) + & (buses_df.v_nom >= 10) + ].index.values + ): + id_node = id_mv_node(bus_node) + p_load = ( + loads_df[loads_df.index.str.contains(str(id_node))].p_set.sum() + / cos_phi_load + ) + p_gen = ( + generators_df[ + generators_df.index.str.contains(str(id_node)) + ].p_nom.sum() + / cos_phi_feedin + ) + + elif bus_node in buses_df[buses_df.index.str.contains("gen")].index.values: + p_gen = ( + generators_df[generators_df.bus == bus_node].p_nom.sum() + / cos_phi_feedin + ) + p_load = loads_df[loads_df.bus == bus_node].p_set.sum() / cos_phi_feedin + + else: + p_gen = 0 + p_load = 0 + + return [p_gen, p_load] + + def _circuit_breaker(ring): + """ + finds the circuit of the related ring + Parameters + ---------- + ring: + obj:'dict` + Dictionary with name of sorted nodes in the ring + Returns + ------- + obj: str + the name of circuit breaker + """ + circuit_breaker = [] + for node in ring: + + for switch in switches_df.bus_closed.values: + if switch in node: + circuit_b = switches_df.loc[ + switches_df.bus_closed == node, "bus_closed" + ].index[0] + circuit_breaker.append(circuit_b) + else: + continue + return circuit_breaker[0] + + def _change_dataframe(node_cb, ring): + + circuit_breaker = _circuit_breaker(ring) + + if node_cb != switches_df.loc[circuit_breaker, "bus_closed"]: + + node_existing = switches_df.loc[circuit_breaker, "bus_closed"] + new_virtual_bus = f"virtual_{node_cb}" + # if the adjacent node is previous circuit breaker + if f"virtual_{node2}" in mv_grid.graph.adj[node_cb]: + branch = mv_grid.graph.adj[node_cb][f"virtual_{node2}"]["branch_name"] + else: + branch = mv_grid.graph.adj[node_cb][node2]["branch_name"] + # Switch + # change bus0 + switches_df.loc[circuit_breaker, "bus_closed"] = node_cb + # change bus1 + switches_df.loc[circuit_breaker, "bus_open"] = new_virtual_bus + # change branch + switches_df.loc[circuit_breaker, "branch"] = branch + + # Bus + x_coord = buses_df.loc[node_cb, "x"] + y_coord = buses_df.loc[node_cb, "y"] + buses_df.rename(index={node_existing: new_virtual_bus}, inplace=True) + buses_df.loc[new_virtual_bus, "x"] = x_coord + buses_df.loc[new_virtual_bus, "y"] = y_coord + + buses_df.rename( + index={f"virtual_{node_existing}": node_existing}, inplace=True + ) + + # Line + lines_df.loc[ + lines_df.bus0 == f"virtual_{node_existing}", "bus0" + ] = node_existing + if lines_df.loc[branch, "bus0"] == node_cb: + lines_df.loc[branch, "bus0"] = new_virtual_bus + else: + lines_df.loc[branch, "bus1"] = new_virtual_bus + else: + logging.info("The location of switch disconnector has not changed") + + rings = _sort_rings(remove_mv_station=True) + for ring in rings: + node_ring_dictionary = get_subtree_of_nodes(ring, graph) + node_ring_df = pd.DataFrame.from_dict(node_ring_dictionary, orient="index") + + node_peak_d = {} + for index, value in node_ring_df.iterrows(): + total_peak_gen = 0 + total_peak_load = 0 + if value[0] is not None: + for v in value: + if v is None: + continue + # sum the load and generation of all subtree nodes + total_peak_gen += _calculate_peak_load_gen(v)[0] + total_peak_load += _calculate_peak_load_gen(v)[1] + # sum the load and generation of nodes of subtree and tree itself + total_peak_gen = total_peak_gen + _calculate_peak_load_gen(index)[0] + total_peak_load = total_peak_load + _calculate_peak_load_gen(index)[1] + else: + total_peak_gen += _calculate_peak_load_gen(index)[0] + total_peak_load += _calculate_peak_load_gen(index)[1] + node_peak_d.setdefault(index, []).append(total_peak_gen) + node_peak_d.setdefault(index, []).append(total_peak_load) + node_peak_df = pd.DataFrame.from_dict(node_peak_d, orient="index") + node_peak_df.rename( + columns={0: "total_peak_gen", 1: "total_peak_load"}, inplace=True + ) + + diff_min = 10e9 + if mode == "load": + node_peak_data = node_peak_df.total_peak_load + elif mode == "generation": + node_peak_data = node_peak_df.total_peak_gen + elif mode == "loadgen": + # is ring dominated by load or generation? + # (check if there's more load than generation in ring or vice versa) + if sum(node_peak_df.total_peak_load) > sum(node_peak_df.total_peak_gen): + node_peak_data = node_peak_df.total_peak_load + else: + node_peak_data = node_peak_df.total_peak_gen + else: + raise ValueError("parameter 'mode' is invalid!") + + for ctr in range(len(node_peak_df.index)): + + # split route and calc demand difference + route_data_part1 = sum(node_peak_data[0:ctr]) + route_data_part2 = sum(node_peak_data[ctr : len(node_peak_df.index)]) + + diff = abs(route_data_part1 - route_data_part2) + if diff <= diff_min: + diff_min = diff + position = ctr + else: + break + + # new cb location + node_cb = node_peak_df.index[position] + + # check if node is last node of ring + if position < len(node_peak_df.index): + # check which branch to disconnect by determining load difference + # of neighboring nodes + diff2 = abs( + sum(node_peak_data[0 : position + 1]) + - sum(node_peak_data[position + 1 : len(node_peak_data)]) + ) + + if diff2 < diff_min: + + node2 = node_peak_df.index[position + 1] + else: + node2 = node_peak_df.index[position - 1] + _change_dataframe(node_cb, ring) + return node_cb 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 598acbc25..07c040422 100644 --- a/edisgo/tools/plots.py +++ b/edisgo/tools/plots.py @@ -866,7 +866,7 @@ 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. @@ -879,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 ------- @@ -889,68 +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", - timestep: str = "min", - 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` + Selected edisgo_obj to get plotting information from. - 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. + grid : :class:`~.network.grids.Grid` + Grid to plot. If None, the MVGrid of the edisgo_obj is plotted. Default: None. - line_color : str - Defines whereby to choose line colors (and implicitly size). Possible - options are: + 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. + + node_result_selection : str + Defines which values are shown for the voltage of the nodes: - timestep : str or :pandas:`pandas.Timestamp` - Defines which values are shown for the load of the lines and the voltage of the - nodes: + * 'min' + Minimal node voltage of all time steps. + * 'max' (default) + Maximal node voltage of all time steps. - * 'min' (default) - Minimal line load and minimal node voltage of all time steps. - * 'max' - Maximal line load and minimal node voltage of all time steps. - * 'timestep' - Line load and node voltage for the selected time step. + selected_timesteps : :pandas:`pandas.Timestamp` or \ + list(:pandas:`pandas.Timestamp`) or None + Selected time steps to show results for. - grid : :class:`~.network.grids.Grid` or bool - Grid to use as root node. If a grid is given the transformer 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 + * 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 ------- @@ -958,6 +988,114 @@ def draw_plotly( Plotly figure with branches and nodes. """ + if grid is None: + grid = edisgo_obj.topology.mv_grid + + G = grid.graph + + logger.debug(f"selected_timesteps={selected_timesteps}") + + 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 + + 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 + + if pseudo_coordinates: + G = make_pseudo_coordinates_graph( + G, edisgo_obj.config["grid_connection"]["branch_detour_factor"] + ) + + if node_selection: + G = G.subgraph(node_selection) + if not list(G.nodes()): + raise ValueError("Selected nodes are not in the selected grid.") + + # 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]) + + 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", @@ -972,123 +1110,82 @@ def get_coordinates_for_edge(edge): x1, y1 = transformer_4326_to_3035.transform(x1, y1) return x0, y0, x1, y1 - line_color_options = ["loading", "relative_loading", "reinforce"] - if line_color not in line_color_options: - raise KeyError(f"Line colors need to be one of {line_color_options}") - - if edisgo_obj.results.s_res.empty and edisgo_obj.results.v_res.empty: - if line_color in ["loading", "relative_loading"]: - raise ValueError("No results to show. -> Run power flow.") - if node_color in ["voltage_deviation"]: - raise ValueError("No results to show. -> Run power flow.") - - if G is None: - G = edisgo_obj.topology.mv_grid.graph - - # 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"] - elif not grid: - 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"] - x_root, y_root = transformer_4326_to_3035.transform(x_root, y_root) - # Select the values for loads and nodes. - s_res_view = edisgo_obj.results.s_res.T.index.isin( - [edge[2]["branch_name"] for edge in G.edges.data()] - ) - v_res_view = edisgo_obj.results.v_res.T.index.isin([node for node in G.nodes]) - if timestep == "min": - s_res = edisgo_obj.results.s_res.T.loc[s_res_view].T.min() - v_res = edisgo_obj.results.v_res.T.loc[v_res_view].T.min() - elif timestep == "max": - s_res = edisgo_obj.results.s_res.T.loc[s_res_view].T.max() - v_res = edisgo_obj.results.v_res.T.loc[v_res_view].T.max() - else: - s_res = edisgo_obj.results.s_res.T.loc[s_res_view, timestep] - v_res = edisgo_obj.results.v_res.T.loc[v_res_view, timestep] + def plot_line_text(): + middle_node_x = [] + middle_node_y = [] + middle_node_text = [] - # 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) - 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"] - branch_name = edge[2]["branch_name"] + text = str(branch_name) + if power_flow_results: + text += "
" + "Loading = " + str(s_res.loc[branch_name]) - text = str(branch_name) - try: - text += "
" + "Loading = " + str(s_res.loc[branch_name]) - except KeyError: - logger.debug( - f"Could not find loading for branch {branch_name}", exc_info=True - ) - text = text - - 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_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, - ) - data = [middle_node_scatter] - # line plot - showscale = True - if line_color == "loading": - color_min = s_res.T.min() - color_max = s_res.T.max() - colorscale = "YlOrRd" - elif line_color == "relative_loading": - color_min = 0 - color_max = 1 - colorscale = "YlOrRd" - elif line_color == "reinforce": - color_min = 0 - color_max = 1 - colorscale = [[0, "green"], [1, "red"]] - else: - showscale = False + middle_node_text.append(text) + + 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, + ) + return [middle_node_scatter] - for edge in G.edges(data=True): - 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] + def plot_lines(): - branch_name = edge[2]["branch_name"] + showscale = True - if line_color == "reinforce": - try: - # Possible distinction between added parallel lines and changed lines + if line_color == "loading": + color_min = s_res.min() + color_max = s_res.max() + colorscale = "YlOrRd" + elif line_color == "relative_loading": + 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: + showscale = False + + data_line_plot = [] + for edge in G.edges(data=True): + + 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] + + branch_name = edge[2]["branch_name"] + + 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" @@ -1109,163 +1206,189 @@ def get_coordinates_for_edge(edge): color = "red" else: color = "black" - except Exception: - 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, - ) - 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 = "green" - else: - color = "black" - - edge_scatter = go.Scatter( - mode="lines", - x=edge_x, - y=edge_y, - hoverinfo="none", - opacity=0.5, - showlegend=False, - line=dict( - width=2, - color=color, - ), - ) - data.append(edge_scatter) - - colorbar_edge_scatter = go.Scatter( - mode="markers", - x=[None], - y=[None], - marker=dict( - colorbar=dict( - title="Lines", xanchor="left", titleside="right", x=1.17, thickness=15 - ), - colorscale=colorscale, - cmax=color_max, - cmin=color_min, - showscale=showscale, - ), - ) + 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 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, 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 * 0.9, + vmin=color_min, + vmax=color_max, + cmap_name=colorscale, + ) + if loading > s_nom: + color = "indigo" + else: + 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) - data.append(colorbar_edge_scatter) + if line_color: + line_color_title = { + "loading": "Loading in MVA", + "relative_loading": "Relative loading in p.u.", + "reinforce": "Reinforce", + } - # node plot - node_x = [] - node_y = [] + 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, + ), + ) - 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 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 = [] - 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", - xanchor="left", - titleside="right", - ) - colorscale = "RdBu" - cmid = 0 - - else: - 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" - ) + 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) + else: + node_colors = "grey" + cmid = None + colorscale = None + colorbar = None + showscale = False - 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) + 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) - v = v_res.loc[node] - text += "
" + "v = " + str(v) + 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) - except KeyError: - logger.debug(f"Failed to add text for node {node}.", exc_info=True) - text = text + 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_scatter = go.Scatter( - x=node_x, - y=node_y, - mode="markers", - hoverinfo="text", - text=node_text, - marker=dict( - showscale=True, - colorscale=colorscale, - color=node_colors, - size=8, - cmid=cmid, - line_width=2, - colorbar=colorbar, - ), - ) - data.append(node_scatter) + + 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, showlegend=False, @@ -1285,7 +1408,19 @@ def get_coordinates_for_edge(edge): ), ), ) - + 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 @@ -1303,6 +1438,7 @@ def chosen_graph( Grid name. Can be either 'Grid' to select the MV grid with all LV grids or the name of the MV grid to select only the MV grid or the name of one of the LV grids of the eDisGo object to select a specific LV grid. + Returns ------- (:networkx:`networkx.Graph`, :class:`~.network.grids.Grid` or bool) @@ -1333,7 +1469,9 @@ def chosen_graph( return G, grid -def dash_plot(edisgo_objects: EDisGo | dict[str, EDisGo]) -> JupyterDash: +def plot_dash_app( + edisgo_objects: EDisGo | dict[str, EDisGo], debug: bool = False +) -> JupyterDash: """ Generates a jupyter dash app from given eDisGo object(s). @@ -1344,6 +1482,15 @@ def dash_plot(edisgo_objects: EDisGo | dict[str, EDisGo]) -> JupyterDash: objects pass a dictionary with the eDisGo objects as values and the respective eDisGo object names as keys. + debug : bool + Debugging for the dash app: + + * False (default) + Disable debugging for the dash app. + * True + Enable debugging for the dash app. + + Returns ------- JupyterDash @@ -1357,42 +1504,49 @@ def dash_plot(edisgo_objects: EDisGo | dict[str, EDisGo]) -> JupyterDash: 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") + raise ValueError("edisgo_objects are not matching.") else: edisgo_name_list = ["edisgo_obj"] edisgo_obj_1 = edisgo_objects 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 = ["reinforce", "loading", "relative_loading"] - node_plot_modes = ["adjacencies", "voltage_deviation"] + line_plot_modes = ["relative_loading", "loading", "reinforce"] + node_plot_modes = ["voltage_deviation", "adjacencies"] - if edisgo_obj_1.timeseries.is_worst_case: + 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 = [ - "min", - "max", - ] + edisgo_obj_1.timeseries.timeindex_worst_cases.index.to_list() + worst_case_series.index[worst_case_series.to_list().index(value)] + for value in timestep_values + ] else: - timestep_labels = [ - "min", - "max", - ] + edisgo_obj_1.timeseries.timeindex.values.to_list() - - timestep_values = ["min", "max"] + edisgo_obj_1.timeseries.timeindex.to_list() - timestep_option = list() - for i in range(0, len(timestep_labels)): - timestep_option.append( - {"label": timestep_labels[i], "value": timestep_values[i]} - ) + 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}") - padding = 0 + 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( @@ -1470,14 +1624,28 @@ def dash_plot(edisgo_objects: EDisGo | dict[str, EDisGo]) -> JupyterDash: ), html.Div( [ - html.Label("Pseudo coordinates"), - dcc.RadioItems( - id="radioitems_pseudo_coordinates", + html.Label("Line plot mode"), + dcc.Dropdown( + id="dropdown_line_plot_mode", options=[ - {"label": "False", "value": False}, - {"label": "True", "value": True}, + {"label": i, "value": i} + for i in line_plot_modes ], - value=False, + 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}, @@ -1494,14 +1662,14 @@ def dash_plot(edisgo_objects: EDisGo | dict[str, EDisGo]) -> JupyterDash: [ html.Div( [ - html.Label("Line plot mode"), - dcc.Dropdown( - id="dropdown_line_plot_mode", + html.Label("Pseudo coordinates"), + dcc.RadioItems( + id="radioitems_pseudo_coordinates", options=[ - {"label": i, "value": i} - for i in line_plot_modes + {"label": "False", "value": False}, + {"label": "True", "value": True}, ], - value=line_plot_modes[0], + value=False, ), ], style={"padding": padding, "flex": 1}, @@ -1520,6 +1688,20 @@ def dash_plot(edisgo_objects: EDisGo | dict[str, EDisGo]) -> JupyterDash: ], 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", @@ -1530,14 +1712,50 @@ def dash_plot(edisgo_objects: EDisGo | dict[str, EDisGo]) -> JupyterDash: ), html.Div( [ - html.Label("Timestep"), - dcc.Dropdown( - id="timestep", - options=timestep_option, - value=timestep_option[0]["value"], + 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={"padding": padding, "flex": 1}, + style={ + "display": "flex", + "flex-direction": "row", + "padding": 0, + "flex": 1, + }, ), html.Div( [ @@ -1550,50 +1768,102 @@ def dash_plot(edisgo_objects: EDisGo | dict[str, EDisGo]) -> JupyterDash: 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("timestep", "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, - selected_timestep, + 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) - if pseudo_coordinates: - G = make_pseudo_coordinates_graph(G) - fig_1 = draw_plotly( + + 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 = None + + app.logger.debug(f"selected_timesteps={selected_timesteps}") + + fig_1 = plot_plotly( edisgo_obj=edisgo_obj, - G=G, + grid=grid, line_color=selected_line_plot_mode, node_color=selected_node_plot_mode, - timestep=selected_timestep, - grid=grid, + 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) - if pseudo_coordinates: - G = make_pseudo_coordinates_graph(G) - fig_2 = draw_plotly( + + fig_2 = plot_plotly( edisgo_obj=edisgo_obj, - G=G, + grid=grid, line_color=selected_line_plot_mode, node_color=selected_node_plot_mode, - timestep=selected_timestep, - grid=grid, + 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 @@ -1618,14 +1888,28 @@ def update_figure( ), html.Div( [ - html.Label("Pseudo coordinates"), - dcc.RadioItems( - id="radioitems_pseudo_coordinates", + html.Label("Line plot mode"), + dcc.Dropdown( + id="dropdown_line_plot_mode", options=[ - {"label": "False", "value": False}, - {"label": "True", "value": True}, + {"label": i, "value": i} + for i in line_plot_modes ], - value=False, + 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}, @@ -1642,14 +1926,14 @@ def update_figure( [ html.Div( [ - html.Label("Line plot mode"), - dcc.Dropdown( - id="dropdown_line_plot_mode", + html.Label("Pseudo coordinates"), + dcc.RadioItems( + id="radioitems_pseudo_coordinates", options=[ - {"label": i, "value": i} - for i in line_plot_modes + {"label": "False", "value": False}, + {"label": "True", "value": True}, ], - value=line_plot_modes[0], + value=False, ), ], style={"padding": padding, "flex": 1}, @@ -1668,6 +1952,20 @@ def update_figure( ], 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", @@ -1678,14 +1976,47 @@ def update_figure( ), html.Div( [ - html.Label("Timestep"), - dcc.Dropdown( - id="timestep", - options=timestep_option, - value=timestep_option[0]["value"], + 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={"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"})], @@ -1695,31 +2026,80 @@ 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("timestep", "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( + def update_figure_single( selected_grid, selected_line_plot_mode, selected_node_plot_mode, pseudo_coordinates, - selected_timestep, + 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 = None + + app.logger.debug(f"selected_timesteps={selected_timesteps}") + (G, grid) = chosen_graph(edisgo_obj_1, selected_grid) - if pseudo_coordinates: - G = make_pseudo_coordinates_graph(G) - fig = draw_plotly( + fig = plot_plotly( edisgo_obj=edisgo_obj_1, - G=G, + grid=grid, line_color=selected_line_plot_mode, node_color=selected_node_plot_mode, - timestep=selected_timestep, - grid=grid, + 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 @@ -1727,9 +2107,11 @@ def update_figure( return app -def show_dash_plot( +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). @@ -1741,8 +2123,24 @@ def show_dash_plot( objects pass a dictionary with the eDisGo objects as values and the respective eDisGo object names as keys. - debug: bool - Enables debugging of the jupyter dash app. + 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 = dash_plot(edisgo_objects) - app.run_server(mode="inline", debug=debug, height=700) + 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 index 60e446c0a..a42654ec9 100644 --- a/edisgo/tools/pseudo_coordinates.py +++ b/edisgo/tools/pseudo_coordinates.py @@ -12,6 +12,8 @@ from pyproj import Transformer if TYPE_CHECKING: + from networkx import Graph + from edisgo import EDisGo logger = logging.getLogger(__name__) @@ -22,8 +24,27 @@ # Pseudo coordinates -def make_coordinates(graph_root, branch_detour_factor=1.3): - # EDisGo().config["grid_connection"]["branch_detour_factor"]): +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 @@ -34,6 +55,10 @@ def coordinate_source(pos_start, length, node_numerator, node_total_numerator): 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 ): @@ -46,6 +71,8 @@ def coordinate_branch( 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 @@ -56,6 +83,9 @@ def coordinate_longest_path(pos_start, angle_offset, length): 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: @@ -71,6 +101,7 @@ def coordinate_longest_path_neighbor(pos_start, angle_offset, length, direction) 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() @@ -78,6 +109,7 @@ def coordinate_longest_path_neighbor(pos_start, angle_offset, length, direction) 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(): @@ -100,6 +132,7 @@ def coordinate_longest_path_neighbor(pos_start, angle_offset, length, direction) 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( @@ -159,27 +192,60 @@ def coordinate_longest_path_neighbor(pos_start, angle_offset, length, direction) 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 grids. + Generates pseudo coordinates for all LV grids and optionally MV grid. Parameters ---------- edisgo_root : :class:`~.EDisGo` - eDisGo Object + eDisGo object mv_coordinates : bool, optional - If True pseudo coordinates are also generated for mv_grid. - Default: False + If True pseudo coordinates are also generated for MV grid. + Default: False. + Returns ------- - edisgo_object : :class:`~.EDisGo` - eDisGo object with coordinates for all nodes + :class:`~.EDisGo` + eDisGo object with pseudo coordinates for all LV nodes and optionally MV nodes. """ start_time = time() - logger.info( + logger.debug( "Start - Making pseudo coordinates for grid: {}".format( str(edisgo_root.topology.mv_grid) ) @@ -194,45 +260,13 @@ def make_pseudo_coordinates( for grid in grids: logger.debug("Make pseudo coordinates for: {}".format(grid)) G = grid.graph - x0, y0 = G.nodes[list(nx.nodes(G))[0]]["pos"] - G = make_coordinates(G) - x0, y0 = coor_transform.transform(x0, y0) + 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"] - x, y = coor_transform_back.transform(x + x0, y + y0) edisgo_obj.topology.buses_df.loc[node, "x"] = x edisgo_obj.topology.buses_df.loc[node, "y"] = y - logger.info("Finished in {}s".format(time() - start_time)) + logger.debug("Finished in {}s".format(time() - start_time)) return edisgo_obj - - -def make_pseudo_coordinates_graph(G): - """ - Generates pseudo coordinates for one graph. - - Parameters - ---------- - edisgo_root : :class:`~.EDisGo` - eDisGo Object - mv_coordinates : bool, optional - If True pseudo coordinates are also generated for mv_grid. - Default: False - Returns - ------- - edisgo_object : :class:`~.EDisGo` - eDisGo object with coordinates for all nodes - - """ - start_time = time() - logger.info("Start - Making pseudo coordinates for graph") - - x0, y0 = G.nodes[list(nx.nodes(G))[0]]["pos"] - G = make_coordinates(G) - 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.info("Finished in {}s".format(time() - start_time)) - return G 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 fad8769c0..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 show_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", @@ -99,15 +112,6 @@ "download_ding0_example_grid()" ] }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "ding0_grid = os.path.join(os.path.expanduser(\"~\"), \".edisgo\", \"ding0_test_network\")" - ] - }, { "cell_type": "markdown", "metadata": {}, @@ -121,18 +125,20 @@ "metadata": {}, "outputs": [], "source": [ - "edisgo_root = EDisGo(ding0_grid=ding0_grid)\n", - "edisgo_root.set_time_series_worst_case_analysis()\n", - "edisgo_root.analyze()" + "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_copy = copy.deepcopy(edisgo_root)" + "edisgo_root = EDisGo(ding0_grid=ding0_grid)\n", + "edisgo_root.set_time_series_worst_case_analysis()" ] }, { @@ -141,8 +147,8 @@ "metadata": {}, "outputs": [], "source": [ - "edisgo_reinforced = copy.deepcopy(edisgo_root)\n", - "edisgo_reinforced.reinforce()" + "edisgo_analyzed = copy.deepcopy(edisgo_root)\n", + "edisgo_analyzed.analyze()" ] }, { @@ -151,7 +157,8 @@ "metadata": {}, "outputs": [], "source": [ - "edisgo_reinforced.results.equipment_changes.loc[\"Line_10006\",\"change\"]=\"added\"" + "edisgo_reinforced = copy.deepcopy(edisgo_root)\n", + "edisgo_reinforced.reinforce()" ] }, { @@ -167,7 +174,7 @@ "tags": [] }, "source": [ - "### draw_plotly function" + "### plot_plotly function" ] }, { @@ -176,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", + "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", "\n", - "The different options for used coordinates (set using parameter `grid`) are:\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" ] }, { @@ -215,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" ] }, { @@ -231,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" ] }, { @@ -247,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", + ")" ] }, { @@ -266,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." ] }, @@ -282,10 +332,13 @@ { "cell_type": "code", "execution_count": null, - "metadata": {}, + "metadata": { + "scrolled": true + }, "outputs": [], "source": [ - "show_dash_plot(edisgo_objects=edisgo_root)" + "plot_dash(edisgo_objects=edisgo_analyzed)\n", + "time.sleep(2)" ] }, { @@ -301,7 +354,8 @@ "metadata": {}, "outputs": [], "source": [ - "show_dash_plot(edisgo_objects={\"edisgo_obj_1\": edisgo_root, \"edisgo_obj_2\": edisgo_reinforced})" + "plot_dash(edisgo_objects={\"edisgo_analyzed\": edisgo_analyzed, \"edisgo_reinforced\": edisgo_reinforced})\n", + "time.sleep(1)" ] }, { @@ -319,21 +373,14 @@ "metadata": {}, "outputs": [], "source": [ - "show_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", ")" ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [] } ], "metadata": { @@ -352,7 +399,7 @@ "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", - "version": "3.8.12" + "version": "3.8.10" }, "toc": { "base_numbering": 1, 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/test_examples.py b/tests/test_examples.py index a71c7adcb..750c42c15 100644 --- a/tests/test_examples.py +++ b/tests/test_examples.py @@ -1,3 +1,4 @@ +import logging import os import shutil import subprocess @@ -75,3 +76,8 @@ def test_plot_example_ipynb(self): # os.path.join(examples_dir_path, "edisgo_simple_example.ipynb") # ) # assert errors == [] + + @classmethod + def teardown_class(cls): + logger = logging.getLogger("edisgo") + logger.propagate = True 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 8db6613ce..c8d855ce8 100644 --- a/tests/tools/test_plots.py +++ b/tests/tools/test_plots.py @@ -3,7 +3,7 @@ import pytest from edisgo import EDisGo -from edisgo.tools.plots import chosen_graph, dash_plot, draw_plotly +from edisgo.tools.plots import chosen_graph, plot_dash_app, plot_plotly class TestPlots: @@ -15,50 +15,87 @@ def setup_class(cls): 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" - def test_draw_plotly(self): - # test - edisgo_obj = self.edisgo_root - grid = edisgo_obj.topology.mv_grid - G = grid.graph + @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, + ): - mode_lines = "reinforce" - mode_nodes = "adjacencies" - fig = draw_plotly( - edisgo_obj, G, line_color=mode_lines, node_color=mode_nodes, grid=grid - ) - fig.show() - - edisgo_obj = self.edisgo_reinforced - grid = edisgo_obj.topology.mv_grid - G = grid.graph + 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 - mode_lines = "relative_loading" - mode_nodes = "voltage_deviation" - fig = draw_plotly( - edisgo_obj, G, line_color=mode_lines, node_color=mode_nodes, grid=grid - ) - fig.show() + if grid_name == "None": + grid = None + elif grid_name == "LVGrid": + grid = list(edisgo_obj.topology.mv_grid.lv_grids)[1] - # plotting loading and voltage deviation, with unchanged coordinates - mode_lines = "loading" - mode_nodes = "voltage_deviation" - fig = draw_plotly( - edisgo_obj, G, line_color=mode_lines, node_color=mode_nodes, grid=False - ) - fig.show() - - # plotting reinforced lines and node adjacencies - edisgo_obj = self.edisgo_reinforced - edisgo_obj.results.equipment_changes.loc["Line_10006", "change"] = "added" - G = edisgo_obj.topology.mv_grid.graph - - mode_lines = "reinforce" - mode_nodes = "adjacencies" - fig = draw_plotly( - edisgo_obj, G, line_color=mode_lines, node_color=mode_nodes, grid=False - ) - fig.show() + 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") @@ -67,15 +104,15 @@ def test_chosen_graph(self): 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( + 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_root, "edisgo_2": self.edisgo_reinforced, 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)