diff --git a/grid2op/Space/detailed_topo_description.py b/grid2op/Space/detailed_topo_description.py index bc9941ed..d91755bd 100644 --- a/grid2op/Space/detailed_topo_description.py +++ b/grid2op/Space/detailed_topo_description.py @@ -8,10 +8,12 @@ from typing import Optional import numpy as np - +import networkx as nx +import copy + import grid2op from grid2op.dtypes import dt_int, dt_bool -from grid2op.Exceptions import Grid2OpException +from grid2op.Exceptions import Grid2OpException, ImpossibleTopology from grid2op.Space.space_utils import extract_from_dict, save_to_dict @@ -294,6 +296,13 @@ def __init__(self): #: this is automatically set when the detailed topo description #: is processed self._n_shunt : int = -1 + + #: INTERNAL + self._conn_node_to_bbs_conn_node_id = None + + #: INTERNAL + self._connectivity_graph = None + # TODO detailed topo: list per substation ! @classmethod def from_ieee_grid(cls, init_grid : "grid2op.Space.GridObjects.GridObjects"): @@ -452,27 +461,23 @@ def from_ieee_grid(cls, init_grid : "grid2op.Space.GridObjects.GridObjects"): return res def _aux_compute_busbars_sections(self): - cls = type(self) - # bbs = (np.isin(self.switches[:, cls.CONN_NODE_1_ID_COL], self.busbar_section_to_conn_node_id) & - # np.isin(self.switches[:, cls.CONN_NODE_2_ID_COL], self.busbar_section_to_conn_node_id)) - # switches = self.switches[~bbs,:] - import networkx as nx - graph = nx.Graph() - graph.add_edges_from([(el[1], el[2]) for el in self.switches]) - - # NON ! - # d_shortest_path = nx.shortest_path(graph) - # top_vect_cn = (self.conn_node_to_topovect_id != -1).nonzero()[0] - # bbs_cn = (self.busbar_section_to_conn_node_id) - # for tp_el in top_vect_cn: - # for bb_id in bbs_cn: - # sh_p = d_shortest_path[tp_el][bb_id] - # pass + # TODO detailed topo: speed optimization: install graph-tool (but not available with pip...) - # je veux isoler les elements qui, si on enleve les busbar, peuvent atteindre les autres busbars - import pdb - pdb.set_trace() + # import time + # beg_ = time.perf_counter() + self._connectivity_graph = nx.Graph() + self._connectivity_graph.add_edges_from([(el[1], el[2], {"id": switch_id}) for switch_id, el in enumerate(self.switches)]) + # je veux isoler les elements qui, si on enleve les busbar, peuvent atteindre les autres busbars + self._conn_node_to_bbs_conn_node_id = [set() for _ in range(self.conn_node_name.shape[0])] + for busbar_id in self.busbar_section_to_conn_node_id: + tmp_g = copy.deepcopy(self._connectivity_graph) + tmp_g.remove_nodes_from([el for el in self.busbar_section_to_conn_node_id if el != busbar_id]) + conn_nodes = nx.node_connected_component(tmp_g, busbar_id) + for el in conn_nodes: + self._conn_node_to_bbs_conn_node_id[el].add(busbar_id) + # print(time.perf_counter() - beg_) # 2ms for 1 sub + def get_switch_id_ieee(self, conn_node_id: int): """TODO detailed topo @@ -537,9 +542,12 @@ def compute_switches_position_ieee(self, topo_vect, shunt_bus): def compute_switches_position(self, topo_vect: np.ndarray, - shunt_bus: Optional[np.ndarray]=None): + shunt_bus: Optional[np.ndarray]=None, + subs_changed : Optional[np.ndarray]=None): """This function compute a plausible switches configuration from a given `topo_vect` representation. + + TODO detailed topo: documentation Parameters ---------- @@ -554,44 +562,277 @@ def compute_switches_position(self, a numpy boolean array. """ + # TODO detailed topo: input the previous switch state + if topo_vect.shape[0] != self._dim_topo: raise Grid2OpException("Incorrect input size for the topology vector.") if shunt_bus is not None and shunt_bus.shape[0] != self._n_shunt: raise Grid2OpException("Incorrect size for the shunt bus vector.") - # TODO detailed topo: input a substation mask for example - # TODO detailed topo: check that the nodes id are consistent (starts at 1 mainly) - - # TODO detailed topo + if topo_vect[topo_vect != -1].min() < 1: + raise Grid2OpException("In grid2op buses are labelled starting from 1 and not 0 " + "(check your `topo_vect` input)") + if shunt_bus is not None and shunt_bus[shunt_bus != -1].min() < 1: + raise Grid2OpException("In grid2op buses are labelled starting from 1 and not 0 " + "(check your `shunt_bus` input)") + if np.unique(topo_vect).shape[0] > self.busbar_section_to_subid.shape[0]: + raise ImpossibleTopology("You ask for more independant buses than there are " + "busbar section on this substation") if self._from_ieee_grid: # specific case for IEEE grid, consistent with the AddDetailedTopoIEEE # class return self.compute_switches_position_ieee(topo_vect, shunt_bus) - if shunt_bus is not None: - raise NotImplementedError("TODO detailed topo: shunts are not handled right now") - if (topo_vect == -1).any(): - raise NotImplementedError("TODO detailed topo: cannot disconnect elements right now") + if subs_changed is None: + subs_changed = np.ones(self._n_sub, dtype=dt_bool) + + if subs_changed.shape[0] != self._n_sub: + raise Grid2OpException("Incorrect size for the substation mask") + + if self._conn_node_to_bbs_conn_node_id is None: + self._aux_compute_busbars_sections() + full_res = np.zeros(self.switches.shape[0], dtype=dt_bool) + for sub_id, is_sub_modif in enumerate(subs_changed): + if not is_sub_modif: + continue + mask_this_sub = self.switches[:, type(self).SUB_COL] == sub_id + res_this_sub = self._aux_compute_switches_position_one_sub(sub_id, topo_vect, shunt_bus) + full_res[mask_this_sub] = res_this_sub + return full_res + + def _aux_compute_switches_position_one_sub(self, + sub_id, + topo_vect: np.ndarray, + shunt_bus: Optional[np.ndarray]=None): + # by default they are False - nb_switch = self.switches.shape[0] - nb_conn_node = self.conn_node_name.shape[0] - switches_state = np.zeros(nb_switch, dtype=dt_bool) + nb_switch = self.switches[self.switches[:, type(self).SUB_COL] == sub_id].shape[0] + nb_conn_node = self.conn_node_name[self.conn_node_to_subid == sub_id].shape[0] + switches_state = np.zeros(nb_switch, dtype=dt_bool) # results # whether the switch is already assigned to a bus switch_visited = np.zeros(nb_switch, dtype=dt_bool) - # to which buses a connection node belongs to - conn_node_to_buses = np.zeros(nb_conn_node, dtype=dt_int) - 2 - # whether the connection node is assigned to a bus - conn_node_visited = np.zeros(nb_conn_node, dtype=dt_int) - 1 - while True: - import pdb - pdb.set_trace() - this_bus = 1 + # whether the connectivity node is assigned to a bus + conn_node_visited = np.zeros(nb_conn_node, dtype=dt_bool) + conn_node_to_bus_id = np.zeros(nb_conn_node, dtype=dt_int) + all_pos = ((self.conn_node_to_topovect_id != -1) & (self.conn_node_to_subid == sub_id)).nonzero()[0] + + if shunt_bus is not None: + # add the shunts + all_pos = np.concatenate((all_pos, + ((self.conn_node_to_shunt_id != -1) & (self.conn_node_to_subid == sub_id)).nonzero()[0])) + topo_vect = np.concatenate((topo_vect, shunt_bus)) - topo_this_bus = topo_vect == this_bus + # traverse all objects + main_obj_id = 0 + try: + res = self._dfs_compute_switches_position(topo_vect, + main_obj_id, + all_pos, + switch_visited, + switches_state, + conn_node_visited, + conn_node_to_bus_id) + except RecursionError as exc_: + raise ImpossibleTopology(f"For substation {sub_id}: " + "No topology found, maybe the substation is " + "too large or there is a bug in the implementation. " + "It is most likely due to the fact that does not exist " + "a valid switch state for the input topology, but we " + "exclude a bug or a substation too large.") from exc_ + if res is None: + raise ImpossibleTopology(f"For substation {sub_id}") + return res + + def _dfs_compute_switches_position(self, + topo_vect, + main_obj_id, + all_pos, + switch_visited, + switches_state, + conn_node_visited, + conn_node_to_bus_id): + """should be use for one substation only, otherwise it will not work !""" + if main_obj_id >= len(all_pos): + return switch_visited + + if switch_visited.all(): + # TODO detailed topo do I have to check if result topo is correct + return None + + el_cn_id = all_pos[main_obj_id] + my_bus = topo_vect[self.conn_node_to_topovect_id[el_cn_id]] + cn_bbs_possible = self._conn_node_to_bbs_conn_node_id[el_cn_id] + if my_bus == -1: + # the object is disconnected, I suppose here that there exist + # a switch that directly control this element. + # With this hyp. this switch will never be changed + # so there is nothing to do. + conn_node_visited[el_cn_id] = True + main_obj_id = self._aux_find_next_el_id(main_obj_id, all_pos, conn_node_visited) + if main_obj_id is not None: + # I still need to visit some other elements + this_res = self._dfs_compute_switches_position(topo_vect, + main_obj_id, + all_pos, + switch_visited, + switches_state, + conn_node_visited, + conn_node_to_bus_id) + return this_res + # all elements have been visited + return switches_state - return switches_state + for cn_bbs in cn_bbs_possible: # chose a busbar section + n_switch_visited = copy.deepcopy(switch_visited) + n_switches_state = copy.deepcopy(switches_state) + n_conn_node_to_bus_id = copy.deepcopy(conn_node_to_bus_id) + n_conn_node_visited = copy.deepcopy(conn_node_visited) + + if conn_node_visited[cn_bbs]: + if my_bus != conn_node_to_bus_id[cn_bbs]: + # cannot assign on the same busbar section two objects not on the same bus + # so I need to "backtrack" + continue + + elif (conn_node_to_bus_id == my_bus).any(): + # there is already an element connected to "my" bus, so I need to connect both busbars + which_other_bbs = (conn_node_to_bus_id[self.busbar_section_to_conn_node_id] == my_bus).nonzero()[0] + other_bbs_cn_ids = self.busbar_section_to_conn_node_id[which_other_bbs] + for other_bbs_cn in other_bbs_cn_ids: + this_tmp_g = copy.deepcopy(self._connectivity_graph) + this_tmp_g.remove_nodes_from([el for el in self.busbar_section_to_conn_node_id if el != cn_bbs and el != other_bbs_cn]) + bbs_switch, bbs_cn = self._aux_connect_el_to_switch(other_bbs_cn, cn_bbs, n_switch_visited, n_switches_state, this_tmp_g) + for bbs_sw, bbs_cn_ in zip(bbs_switch, bbs_cn): + # there is a way to connect both busbar sections + # we see if it works out until the end + n_switch_visited[bbs_sw] = True + n_switches_state[bbs_sw] = True + n_conn_node_visited[bbs_cn_] = True + n_conn_node_to_bus_id[bbs_cn_] = my_bus + this_res = self._dfs_compute_switches_position(topo_vect, + main_obj_id, + all_pos, + n_switch_visited, + n_switches_state, + n_conn_node_visited, + n_conn_node_to_bus_id) + if this_res is not None: + return this_res + # I cannot connect two busbars in this case + continue + # graph with all busbars remove except the "correct" one + tmp_g = copy.deepcopy(self._connectivity_graph) + tmp_g.remove_nodes_from([el for el in self.busbar_section_to_conn_node_id if el != cn_bbs]) + + # check if "main" element can be connected to this busbar + possible_switches_tmp, cn_visited_tmp = self._aux_connect_el_to_switch(el_cn_id, cn_bbs, switch_visited, switches_state, tmp_g) + + if len(possible_switches_tmp) == 0: + # this is not possible, I should move to other choice + continue + + something_works = False + this_res = None + n_conn_node_visited[el_cn_id] = True + n_conn_node_to_bus_id[el_cn_id] = my_bus + n_conn_node_visited[cn_visited_tmp] = True + n_conn_node_to_bus_id[cn_visited_tmp] = my_bus + for path in possible_switches_tmp: + n_switch_visited[path] = True + n_switches_state[path] = True + is_working = True + for other_cn_id in all_pos: + # find if all other elements can be assigned to this path (just an assessment for now) + if topo_vect[self.conn_node_to_topovect_id[other_cn_id]] != my_bus: + # nothing to do if the object is not on the same bus + continue + if n_conn_node_visited[other_cn_id]: + # node already visited + continue + + ps_tmp, cns_tmp = self._aux_connect_el_to_switch(other_cn_id, + cn_bbs, + n_switch_visited, + n_switches_state, + self._connectivity_graph) + if len(ps_tmp) == 0: + is_working = False + break + + if len(ps_tmp) == 1: + # both objects are on the same bus and there is only one path + # to connect this object to the main object, so I necessarily + # toggle all switches on this path and continue + tmp_path = ps_tmp[0] + n_switch_visited[tmp_path] = True + n_switches_state[tmp_path] = True + n_conn_node_visited[cns_tmp] = True + n_conn_node_to_bus_id[cns_tmp] = my_bus + + if not is_working: + # this path is not working, I don't use it + continue + else: + # this seems to work, I try to see if I can + # handle all the remaining elements + main_obj_id = self._aux_find_next_el_id(main_obj_id, all_pos, n_conn_node_visited) + if main_obj_id is not None: + # I still need to visit some other elements + this_res = self._dfs_compute_switches_position(topo_vect, + main_obj_id, + all_pos, + n_switch_visited, + n_switches_state, + n_conn_node_visited, + n_conn_node_to_bus_id) + else: + # I found a correct path + return n_switches_state + if this_res is not None: + something_works = True + break # I found a solution + else: + # I need to back track + something_works = False + if something_works: + # I found a solution valid for everything + return this_res + else: + # no solution found, this bus is not possible + continue + # If I end up here it's because + # none of the `cn_bbs in cn_bbs_possible` are working + # so there is not solution + return None + def _aux_find_next_el_id(self, main_obj_id, all_pos, n_conn_node_visited): + still_more_els = True + while n_conn_node_visited[all_pos[main_obj_id]]: + main_obj_id += 1 + if main_obj_id >= len(all_pos): + still_more_els = False + break + if still_more_els: + return main_obj_id + return None + + def _aux_connect_el_to_switch(self, el_cn_id, cn_bbs, switch_visited, switches_state, tmp_g): + """connect the connectivity node `el_cn_id` (representing an element) to + the connectivity node representing a busbar `cn_bbs` and should return all possible ways + to connect it without having to traverse another busbar + """ + paths = [el for el in nx.all_simple_paths(tmp_g, el_cn_id, cn_bbs)] + tmp = [np.array([self._connectivity_graph[pp[i]][pp[i+1]]["id"] for i in range(len(pp)-1)]) for pp in paths] # retrieve the switch id + res_switch = [] + res_cn = [] + for el, cn_path in zip(tmp, paths): + if not (switches_state[el] | ~switch_visited[el]).all(): + continue + res_switch.append(el) + res_cn.append(np.array(cn_path)) + return res_switch, res_cn + def from_switches_position(self, switches_state : np.ndarray, subs_changed : Optional[np.ndarray]=None): diff --git a/grid2op/tests/test_compute_switch_pos.py b/grid2op/tests/test_compute_switch_pos.py index 934723c6..68ecfa96 100644 --- a/grid2op/tests/test_compute_switch_pos.py +++ b/grid2op/tests/test_compute_switch_pos.py @@ -9,25 +9,19 @@ import unittest import pandas as pd import os -import warnings import numpy as np -import hashlib +import networkx as nx from grid2op.tests.helper_path_test import * import grid2op -from grid2op.dtypes import dt_bool -from grid2op.Action import BaseAction, CompleteAction -from grid2op.Observation import BaseObservation -from grid2op.Runner import Runner -from grid2op.Backend import PandaPowerBackend -from grid2op.Space import AddDetailedTopoIEEE, DetailedTopoDescription -from grid2op.Agent import BaseAgent -from grid2op.Exceptions import AmbiguousAction +from grid2op.Space import DetailedTopoDescription +from grid2op.Exceptions import ImpossibleTopology import pdb -REF_HASH = 'c8296b80b3b920b2971bd82e93f998a043ccb3738f04ca0d3f23f524306da8e95109f5af27e28a85597151b3988840674f4e6ad1efa69dbab1a2174765f330ec' + class TestComputeSwitchPos(unittest.TestCase): + # TODO detailed topo: not tested in case of shunt def _aux_read_case(self, case_id): path_data = os.path.join(PATH_DATA_TEST, "test_detailed_topo") switches = pd.read_csv(os.path.join(path_data, f"test_topo_connections{case_id}.txt"), @@ -41,7 +35,7 @@ def _aux_read_case(self, case_id): all_nodes = np.unique(np.concatenate((switches["node1"].values, switches["node2"].values))) nb_switch = switches.shape[0] dtd.conn_node_name = np.array([None for _ in all_nodes], dtype=str) - dtd.conn_node_to_subid = np.zeros(nb_switch, dtype=int) + dtd.conn_node_to_subid = np.zeros(len(all_nodes), dtype=int) dtd.switches = np.zeros((nb_switch, 3), dtype=int) dtd.switches[:, 0] = 0 dtd.switches[:, 1] = switches["node1"].values @@ -83,11 +77,105 @@ def _aux_read_case(self, case_id): def setUp(self): super().setUp() - def test_case1(self): + def _aux_test_switch_topo(self, dtd, results, switches, extra_str=""): + graph = nx.Graph() + graph.add_edges_from([(el[1], el[2], {"id": switch_id}) for switch_id, el in enumerate(dtd.switches) if switches[switch_id]]) + tmp = list(nx.connected_components(graph)) + expected_buses = np.unique(results[results != -1]) + assert len(tmp) == expected_buses.shape[0], f"found {len(tmp)} buses when asking for {np.unique(results).shape[0]}" + # check that element in results connected together are connected together + # and check that the elements that are not connected together are not + for el_1 in range(results.shape[0]): + th_bus_1 = results[el_1] + conn_bus_1 = dtd.line_or_to_conn_node_id[el_1] + conn_comp1 = np.array([conn_bus_1 in el for el in tmp]).nonzero()[0] + if th_bus_1 == -1: + assert conn_comp1.shape[0] == 0, f"Error for element {el_1}: it should be disconnected but does not appear to be" + continue + for el_2 in range(el_1 + 1, results.shape[0]): + th_bus_2 = results[el_2] + conn_bus_2 = dtd.line_or_to_conn_node_id[el_2] + conn_comp2 = np.array([conn_bus_2 in el for el in tmp]).nonzero()[0] + if th_bus_2 == -1: + assert conn_comp2.shape[0] == 0, f"Error for element {el_2}: it should be disconnected but does not appear to be" + elif th_bus_1 == th_bus_2: + # disconnected element should not be together + assert conn_comp1 == conn_comp2, f"Error for elements: {el_1} and {el_2}: they should be on the same bus but are not, {extra_str}" + else: + assert conn_comp1 != conn_comp2, f"Error for elements: {el_1} and {el_2}: they should NOT be on the same bus but they are, {extra_str}" + + def test_case1_standard(self): + """test I can compute this for the reference test case""" + dtd, results = self._aux_read_case("1") + dtd._aux_compute_busbars_sections() + switches = dtd.compute_switches_position(results) + self._aux_test_switch_topo(dtd, results, switches) + + def test_case1_all_samebus(self): + """test I can connect every element to the same bus, even if the said bus is not 1""" dtd, results = self._aux_read_case("1") dtd._aux_compute_busbars_sections() + for bus in range(dtd.busbar_section_to_subid.shape[0]): + results[:] = bus + 1 + switches = dtd.compute_switches_position(results) + self._aux_test_switch_topo(dtd, results, switches) + + def test_case1_impossible_toomuch_buses(self): + """test that when someone ask to connect something to a bus too high (too many buses) then it does not work""" + dtd, results = self._aux_read_case("1") + dtd._aux_compute_busbars_sections() + bus_id_too_high = dtd.busbar_section_to_subid.shape[0] + 1 + for el_id in range(len(results)): + els = np.array(list(dtd._conn_node_to_bbs_conn_node_id[dtd.line_or_to_conn_node_id[el_id]])) + results[el_id] = (dtd.busbar_section_to_conn_node_id == els[el_id % len(els)]).nonzero()[0][0] + 1 + # test that it works in general case with all possible buses switches = dtd.compute_switches_position(results) - + self._aux_test_switch_topo(dtd, results, switches) + + # now test that it breaks if the index of a bus it too high + for el_id in range(len(results)): + tmp = 1 * results + tmp[el_id] = bus_id_too_high + with self.assertRaises(ImpossibleTopology): + switches = dtd.compute_switches_position(tmp) + + def test_case1_impossible_connectivity(self): + """test for some more cases where it would be impossible (forced to connect busbar breaker + for some elements but not for others)""" + dtd, results = self._aux_read_case("1") + dtd._aux_compute_busbars_sections() + results[0] = 1 # to force busbar sec 0 + results[1] = 2 # to force busbar sec 1 + results[2] = 3 # to force busbar sec 3 + results[3] = 4 # to force busbar sec 4 + results[4] = 2 # is directly connected to busbar sec 1 or 3, in this first example I force it to 1 + + # now i force every element to a busbar to which it is directly connected + # so as to make sure it works + for el_id in range(4, len(results)): + els = np.array(list(dtd._conn_node_to_bbs_conn_node_id[dtd.line_or_to_conn_node_id[el_id]])) + results[el_id] = (dtd.busbar_section_to_conn_node_id == els[0]).nonzero()[0][0] + 1 + # should work + switches = dtd.compute_switches_position(results) + self._aux_test_switch_topo(dtd, results, switches) + + # here I force to connect bbs 1 or 3 to bbs 0 + # which contradicts the 4 other constraints above + results[4] = 1 + with self.assertRaises(ImpossibleTopology): + switches = dtd.compute_switches_position(results) + + def test_case1_with_disconnected_element(self): + dtd, results = self._aux_read_case("1") + dtd._aux_compute_busbars_sections() + # disconnect element one by one and check it works + for el_id in range(len(results)): + tmp = 1 * results + tmp[el_id] = -1 + switches = dtd.compute_switches_position(tmp) + self._aux_test_switch_topo(dtd, tmp, switches, f"when disconnecting element {el_id}") + + if __name__ == "__main__": unittest.main() \ No newline at end of file