diff --git a/CHANGELOG.rst b/CHANGELOG.rst index ecb784bc..e7a489d9 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -107,6 +107,11 @@ Native multi agents support: name is added. This behaviour can be turned off by passing `_add_cls_nm_bk=False` when calling `grid2op.make(...)`. If you develop a new Backend, you can also customize the added name by overloading the `get_class_added_name` class method. +- [BREAKING] it is now forbidden to create environment with arguments. + Only key-word arguments are allowed. +- [BREAKING] the way actions is serialized has been changed with respect to the `from_vect` / + `to_vect` method. This might introduce some issues when loading previously saved actions + with this methods. - [FIXED] issue https://github.com/Grid2op/grid2op/issues/657 - [FIXED] missing an import on the `MaskedEnvironment` class - [FIXED] a bug when trying to set the load_p, load_q, gen_p, gen_v by names. @@ -123,7 +128,11 @@ Native multi agents support: environment data - [FIXED] an issue preventing to set the thermal limit in the options if the last simulated action lead to a game over +- [FIXED] some bugs in `act.from_json(...)` due to the handling of the injection modifications. - [FIXED] logos now have the correct URL +- [FIXED] deprecated call to `tostring_rgb` (replaced `tostring_argb`) in the env.render function. +- [FIXED] warnings not properly issued in the AAA test when backend failed to call + `can_handle_XXX` functions (*eg* `can_handle_more_than_2_busbar()` or `can_handle_detachment()`) - [ADDED] possibility to set the "thermal limits" when calling `env.reset(..., options={"thermal limit": xxx})` - [ADDED] possibility to retrieve some structural information about elements with with `gridobj.get_line_info(...)`, `gridobj.get_load_info(...)`, `gridobj.get_gen_info(...)` @@ -132,6 +141,10 @@ Native multi agents support: - [ADDED] a method to check the KCL (`obs.check_kirchhoff`) directly from the observation (previously it was only possible to do it from the backend). This should be used for testing purpose only +- [ADDED] parameters to disable the "redispatching routine" of the environment + (see `params.ENV_DOES_REDISPATCHING`) +- [ADDED] parameters to stop the episode when one of the constraints of one of the + generators is not met (see `params.STOP_EP_IF_SLACK_BREAK_CONSTRAINTS`) - [ADDED] possibility to set the initial time stamp of the observation in the `env.reset` kwargs by using `env.reset(..., options={"init datetime": XXX})` - [IMPROVED] possibility to set the injections values with names @@ -151,12 +164,18 @@ Native multi agents support: does not have shunt information but there are not shunts on the grid. - [IMPROVED] consistency of `MultiMixEnv` in case of automatic_classes (only one class is generated for all mixes) +- [IMRPOVED] handling of disconnected elements in the backend no more + raise error. The base `Backend` class does that. - [IMPROVED] the `act.as_serializable_dict()` to be more 'backend agnostic'as it nows tries to use the name of the elements in the json output - [IMPROVED] the way shunt data are digested in the `BaseAction` class (it is now possible to use the same things as for the other types of element) - [IMPROVED] grid2op does not require the `chronics` folder when using the `FromHandlers` class +- [IMPROVED] the function `action.get_topological_impact(...)` has now a "caching" mechanism + that allows not to recompute it over and over again (this is internal API please do not change + it... unless you know what you are doing) + [1.10.4] - 2024-10-15 ------------------------- diff --git a/docs/conf.py b/docs/conf.py index 5205f702..191c276a 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -22,7 +22,7 @@ author = 'Benjamin Donnot' # The full version, including alpha/beta/rc tags -release = '1.11.0.dev2' +release = '1.11.0.dev3' version = '1.11' diff --git a/getting_started/13_DetachmentOfLoadsAndGenerators.ipynb b/getting_started/13_DetachmentOfLoadsAndGenerators.ipynb new file mode 100644 index 00000000..81e6a5d8 --- /dev/null +++ b/getting_started/13_DetachmentOfLoadsAndGenerators.ipynb @@ -0,0 +1,83 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### Detachment of Loads and Generators\n", + "In emergency conditions, it may be possible / necessary for a grid operator to detach certain loads, generators, or other components in order to prevent a larger blackout. This notebook explores how this can be achieved in Grid2OP. \n", + "\n", + "By default detachment is disabled in all environments, to provide the keyword argument allow_detachment when initializing the environment. The backend must be able to support this feature." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "import matplotlib.pyplot as plt\n", + "import grid2op\n", + "from grid2op.Parameters import Parameters\n", + "from grid2op.PlotGrid import PlotMatplot\n", + "from pathlib import Path\n", + "\n", + "# Setup Environment\n", + "data_path = Path.cwd() / \"grid2op\" / \"data\"\n", + "p = Parameters()\n", + "p.MAX_SUB_CHANGED = 5\n", + "env = grid2op.make(data_path / \"rte_case5_example\", param=p, allow_detachment=True)\n", + "\n", + "# Setup Plotter Utility\n", + "plotter = PlotMatplot(env.observation_space, load_name=True, gen_name=True, dpi=150)\n", + "print(f\"Loads: {env.n_load}, Generators: {env.n_gen}, Storage: {env.n_storage}, Allow Detachment: {env._allow_detachment}\")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Detach the loads at substation 3 and 4. Normally this would cause a game-over, but if allow_detachment is True, the powerflow will be run. Game over in these cases can only occur if the powerflow does not converge." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "load_lookup = {name:i for i,name in enumerate(env.name_load)}\n", + "gen_lookup = {name:i for i,name in enumerate(env.name_gen)}\n", + "act = env.action_space({\"set_bus\":[(env.load_pos_topo_vect[load_lookup[\"load_3_1\"]], -1),\n", + " (env.load_pos_topo_vect[load_lookup[\"load_4_2\"]], -1)]})\n", + "print(act)\n", + "env.set_id(\"00\")\n", + "init_obs = env.reset()\n", + "obs, reward, done, info = env.step(act)\n", + "plotter.plot_obs(obs, figure=plt.figure(figsize=(8,5)))\n", + "plt.show()" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "venv_test", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.12.7" + } + }, + "nbformat": 4, + "nbformat_minor": 2 +} diff --git a/grid2op/Action/_backendAction.py b/grid2op/Action/_backendAction.py index 1e8e869c..ec23c13b 100644 --- a/grid2op/Action/_backendAction.py +++ b/grid2op/Action/_backendAction.py @@ -733,7 +733,66 @@ def _aux_iadd_reconcile_disco_reco(self): self.line_ex_pos_topo_vect, self.last_topo_registered, ) - + + def _aux_iadd_detach(self, other, set_topo_vect : np.ndarray, modif_inj: bool): + cls = type(self) + if other._modif_detach_load: + set_topo_vect[cls.load_pos_topo_vect[other._detach_load]] = -1 + modif_set_bus = True + if other._modif_detach_gen: + set_topo_vect[cls.gen_pos_topo_vect[other._detach_gen]] = -1 + modif_set_bus = True + if other._modif_detach_storage: + set_topo_vect[cls.storage_pos_topo_vect[other._detach_storage]] = -1 + modif_set_bus = True + if modif_inj: + for key, vect_ in other._dict_inj.items(): + if key == "load_p" or key == "load_q": + vect_[other._detach_load] = 0. + elif key == "prod_p": + vect_[other._detach_gen] = 0. + elif key == "prod_v": + vect_[other._detach_gen] = np.nan + else: + raise RuntimeError(f"Unknown key {key} for injection found.") + else: + # TODO when injection is not modified by the action (eg change nothing) + if other._modif_detach_load: + modif_inj = True + other._dict_inj["load_p"] = np.full(cls.n_load, fill_value=np.nan, dtype=dt_float) + other._dict_inj["load_q"] = np.full(cls.n_load, fill_value=np.nan, dtype=dt_float) + other._dict_inj["load_p"][other._detach_load] = 0. + other._dict_inj["load_q"][other._detach_load] = 0. + if other._modif_detach_gen: + modif_inj = True + other._dict_inj["prod_p"] = np.full(cls.n_gen, fill_value=np.nan, dtype=dt_float) + other._dict_inj["prod_p"][other._detach_gen] = 0. + return modif_set_bus, modif_inj + + def _aux_iadd_line_status(self, other: BaseAction, switch_status: np.ndarray, set_status: np.ndarray): + if other._modif_change_status: + self.current_topo.change_status( + switch_status, + self.line_or_pos_topo_vect, + self.line_ex_pos_topo_vect, + self.last_topo_registered, + ) + if other._modif_set_status: + self.current_topo.set_status( + set_status, + self.line_or_pos_topo_vect, + self.line_ex_pos_topo_vect, + self.last_topo_registered, + ) + + # if other._modif_change_status or other._modif_set_status: + ( + self._status_or_before[:], + self._status_ex_before[:], + ) = self.current_topo.get_line_status( + self.line_or_pos_topo_vect, self.line_ex_pos_topo_vect + ) + def __iadd__(self, other : BaseAction) -> Self: """ .. warning:: /!\\\\ Internal, do not use unless you know what you are doing /!\\\\ @@ -760,16 +819,23 @@ def __iadd__(self, other : BaseAction) -> Self: """ - set_status = other._set_line_status + set_status = 1 * other._set_line_status switch_status = other._switch_line_status - set_topo_vect = other._set_topo_vect + set_topo_vect = 1 * other._set_topo_vect switcth_topo_vect = other._change_bus_vect redispatching = other._redispatch storage_power = other._storage_power - + modif_set_bus = other._modif_set_bus + modif_inj = other._modif_inj + cls = type(self) + + # I detachment (before all else) + if cls.detachment_is_allowed and other.has_element_detached(): + modif_set_bus, modif_inj = self._aux_iadd_detach(other, set_topo_vect, modif_inj) + # I deal with injections # Ia set the injection - if other._modif_inj: + if modif_inj: self._aux_iadd_inj(other._dict_inj) # Ib change the injection aka redispatching @@ -781,39 +847,18 @@ def __iadd__(self, other : BaseAction) -> Self: self.storage_power.set_val(storage_power) # II shunts - if type(self).shunts_data_available: + if cls.shunts_data_available: self._aux_iadd_shunt(other) # III line status # this need to be done BEFORE the topology, as a connected powerline will be connected to their old bus. # regardless if the status is changed in the action or not. - if other._modif_change_status: - self.current_topo.change_status( - switch_status, - self.line_or_pos_topo_vect, - self.line_ex_pos_topo_vect, - self.last_topo_registered, - ) - if other._modif_set_status: - self.current_topo.set_status( - set_status, - self.line_or_pos_topo_vect, - self.line_ex_pos_topo_vect, - self.last_topo_registered, - ) - - # if other._modif_change_status or other._modif_set_status: - ( - self._status_or_before[:], - self._status_ex_before[:], - ) = self.current_topo.get_line_status( - self.line_or_pos_topo_vect, self.line_ex_pos_topo_vect - ) - + self._aux_iadd_line_status(other, switch_status, set_status) + # IV topo if other._modif_change_bus: self.current_topo.change_val(switcth_topo_vect) - if other._modif_set_bus: + if modif_set_bus: self.current_topo.set_val(set_topo_vect) # V Force disconnected status @@ -823,7 +868,7 @@ def __iadd__(self, other : BaseAction) -> Self: ) # At least one disconnected extremity - if other._modif_change_bus or other._modif_set_bus: + if other._modif_change_bus or modif_set_bus: self._aux_iadd_reconcile_disco_reco() return self @@ -1444,3 +1489,15 @@ def update_state(self, powerline_disconnected) -> None: ) self.last_topo_registered.update_connected(self.current_topo) self.current_topo.reset() + + def get_load_detached(self): + cls = type(self) + return self.current_topo.values[cls.load_pos_topo_vect] == -1 + + def get_gen_detached(self): + cls = type(self) + return self.current_topo.values[cls.gen_pos_topo_vect] == -1 + + def get_sto_detached(self): + cls = type(self) + return self.current_topo.values[cls.storage_pos_topo_vect] == -1 diff --git a/grid2op/Action/actionSpace.py b/grid2op/Action/actionSpace.py index 2b55406e..5ebec567 100644 --- a/grid2op/Action/actionSpace.py +++ b/grid2op/Action/actionSpace.py @@ -70,8 +70,6 @@ def __init__( Class specifying the rules of the game used to check the legality of the actions. """ - actionClass._add_shunt_data() - actionClass._update_value_set() SerializableActionSpace.__init__(self, gridobj, actionClass=actionClass, _local_dir_cls=_local_dir_cls) self.legal_action = legal_action diff --git a/grid2op/Action/baseAction.py b/grid2op/Action/baseAction.py index 587ee00c..7e68cce8 100644 --- a/grid2op/Action/baseAction.py +++ b/grid2op/Action/baseAction.py @@ -370,6 +370,9 @@ class BaseAction(GridObjects): "curtail", "raise_alarm", "raise_alert", + "detach_load", # new in 1.11.0 + "detach_gen", # new in 1.11.0 + "detach_storage", # new in 1.11.0 } attr_list_vect = [ @@ -388,8 +391,15 @@ class BaseAction(GridObjects): "_curtail", "_raise_alarm", "_raise_alert", + "_detach_load", # new in 1.11.0 + "_detach_gen", # new in 1.11.0 + "_detach_storage", # new in 1.11.0 ] - attr_nan_list_set = set() + # new in 1.11.0 (was not set to nan before in serialization) + attr_nan_list_set = set(["prod_p", + "prod_v", + "load_p", + "load_q"]) attr_list_set = set(attr_list_vect) shunt_added = False @@ -399,6 +409,7 @@ class BaseAction(GridObjects): ERR_ACTION_CUT = 'The action added to me will be cut, because i don\'t support modification of "{}"' ERR_NO_STOR_SET_BUS = 'Impossible to modify the storage bus (with "set") with this action type.' + OBJ_SUPPORT_DETACH = ["load", "gen", "storage"] def __init__(self, _names_chronics_to_backend: Optional[Dict[Literal["loads", "prods", "lines"], Dict[str, str]]]=None): """ @@ -416,6 +427,7 @@ def __init__(self, _names_chronics_to_backend: Optional[Dict[Literal["loads", "p """ GridObjects.__init__(self) + cls = type(self) if _names_chronics_to_backend is not None: # should only be the case for the "init state" action self._names_chronics_to_backend = _names_chronics_to_backend @@ -423,48 +435,48 @@ def __init__(self, _names_chronics_to_backend: Optional[Dict[Literal["loads", "p self._names_chronics_to_backend = None # False(line is disconnected) / True(line is connected) - self._set_line_status = np.full(shape=self.n_line, fill_value=0, dtype=dt_int) + self._set_line_status = np.full(shape=cls.n_line, fill_value=0, dtype=dt_int) self._switch_line_status = np.full( - shape=self.n_line, fill_value=False, dtype=dt_bool + shape=cls.n_line, fill_value=False, dtype=dt_bool ) # injection change - self._dict_inj = {} + self._dict_inj : Dict[Literal["prod_p", "prod_v", "load_p", "load_q"], np.ndarray] = {} # topology changed - self._set_topo_vect = np.full(shape=self.dim_topo, fill_value=0, dtype=dt_int) + self._set_topo_vect = np.full(shape=cls.dim_topo, fill_value=0, dtype=dt_int) self._change_bus_vect = np.full( - shape=self.dim_topo, fill_value=False, dtype=dt_bool + shape=cls.dim_topo, fill_value=False, dtype=dt_bool ) # add the hazards and maintenance usefull for saving. - self._hazards = np.full(shape=self.n_line, fill_value=False, dtype=dt_bool) - self._maintenance = np.full(shape=self.n_line, fill_value=False, dtype=dt_bool) + self._hazards = np.full(shape=cls.n_line, fill_value=False, dtype=dt_bool) + self._maintenance = np.full(shape=cls.n_line, fill_value=False, dtype=dt_bool) # redispatching vector - self._redispatch = np.full(shape=self.n_gen, fill_value=0.0, dtype=dt_float) + self._redispatch = np.full(shape=cls.n_gen, fill_value=0.0, dtype=dt_float) # storage unit vector self._storage_power = np.full( - shape=self.n_storage, fill_value=0.0, dtype=dt_float + shape=cls.n_storage, fill_value=0.0, dtype=dt_float ) # curtailment of renewable energy - self._curtail = np.full(shape=self.n_gen, fill_value=-1.0, dtype=dt_float) + self._curtail = np.full(shape=cls.n_gen, fill_value=-1.0, dtype=dt_float) self._vectorized = None self._lines_impacted = None self._subs_impacted = None # shunts - if type(self).shunts_data_available: + if cls.shunts_data_available: self.shunt_p = np.full( - shape=self.n_shunt, fill_value=np.NaN, dtype=dt_float + shape=cls.n_shunt, fill_value=np.NaN, dtype=dt_float ) self.shunt_q = np.full( - shape=self.n_shunt, fill_value=np.NaN, dtype=dt_float + shape=cls.n_shunt, fill_value=np.NaN, dtype=dt_float ) - self.shunt_bus = np.full(shape=self.n_shunt, fill_value=0, dtype=dt_int) + self.shunt_bus = np.full(shape=cls.n_shunt, fill_value=0, dtype=dt_int) else: self.shunt_p = None self.shunt_q = None @@ -473,13 +485,22 @@ def __init__(self, _names_chronics_to_backend: Optional[Dict[Literal["loads", "p self._single_act = True self._raise_alarm = np.full( - shape=self.dim_alarms, dtype=dt_bool, fill_value=False - ) # TODO + shape=cls.dim_alarms, dtype=dt_bool, fill_value=False + ) self._raise_alert = np.full( - shape=self.dim_alerts, dtype=dt_bool, fill_value=False - ) # TODO + shape=cls.dim_alerts, dtype=dt_bool, fill_value=False + ) + if cls.detachment_is_allowed: + self._detach_load = np.full(cls.n_load, dtype=dt_bool, fill_value=False) + self._detach_gen = np.full(cls.n_gen, dtype=dt_bool, fill_value=False) + self._detach_storage = np.full(cls.n_storage, dtype=dt_bool, fill_value=False) + else: + self._detach_load = None + self._detach_gen = None + self._detach_storage = None + # change the stuff self._modif_inj = False self._modif_set_bus = False @@ -491,9 +512,12 @@ def __init__(self, _names_chronics_to_backend: Optional[Dict[Literal["loads", "p self._modif_curtailment = False self._modif_alarm = False self._modif_alert = False + self._modif_detach_load = False + self._modif_detach_gen = False + self._modif_detach_storage = False @classmethod - def process_shunt_satic_data(cls): + def process_shunt_static_data(cls): if not cls.shunts_data_available: # this is really important, otherwise things from grid2op base types will be affected cls.attr_list_vect = copy.deepcopy(cls.attr_list_vect) @@ -505,9 +529,42 @@ def process_shunt_satic_data(cls): cls.attr_list_vect.remove(el) except ValueError: pass - cls.attr_list_set = set(cls.attr_list_vect) - return super().process_shunt_satic_data() + cls._update_value_set() + return super().process_shunt_static_data() + @classmethod + def process_detachment(cls): + if not cls.detachment_is_allowed: + # this is really important, otherwise things from grid2op base types will be affected + cls.attr_list_vect = copy.deepcopy(cls.attr_list_vect) + cls.authorized_keys = copy.deepcopy(cls.authorized_keys) + # remove the detachment from the list to vector + for el in ["_detach_load", "_detach_gen", "_detach_storage"]: + if el in cls.attr_list_vect: + try: + cls.attr_list_vect.remove(el) + except ValueError: + pass + # remove the detachment from the allowed action + for el in ["detach_load", "detach_gen", "detach_storage"]: + if el in cls.authorized_keys: + cls.authorized_keys.remove(el) + cls._update_value_set() + # else: + # # I support detachment, I need to make sure this is registered + # cls.attr_list_vect = copy.deepcopy(cls.attr_list_vect) + # cls.attr_list_set = copy.deepcopy(cls.attr_list_set) + # # add the detachment from the list to vector + # for el in ["_detach_load", "_detach_gen", "_detach_storage"]: + # if el not in cls.attr_list_vect: + # cls.attr_list_vect.append(el) + # # add the detachment from the allowed action + # for el in ["detach_load", "detach_gen", "detach_storage"]: + # if el not in cls.authorized_keys: + # cls.authorized_keys.add(el) + # cls._update_value_set() + return super().process_detachment() + def copy(self) -> "BaseAction": # sometimes this method is used... return self.__deepcopy__() @@ -530,6 +587,9 @@ def _aux_copy(self, other): "_modif_curtailment", "_modif_alarm", "_modif_alert", + "_modif_detach_load", + "_modif_detach_gen", + "_modif_detach_storage", "_single_act", ] @@ -547,9 +607,13 @@ def _aux_copy(self, other): "_raise_alert", ] - if type(self).shunts_data_available: + cls = type(self) + if cls.shunts_data_available: attr_vect += ["shunt_p", "shunt_q", "shunt_bus"] + if cls.detachment_is_allowed: + attr_vect += ["_detach_load", "_detach_gen", "_detach_storage"] + for attr_nm in attr_simple: setattr(other, attr_nm, getattr(self, attr_nm)) @@ -571,10 +635,6 @@ def __copy__(self) -> "BaseAction": res._subs_impacted = self._subs_impacted return res - - @classmethod - def process_shunt_satic_data(cls): - return super().process_shunt_satic_data() def __deepcopy__(self, memodict={}) -> "BaseAction": res = type(self)() @@ -744,6 +804,18 @@ def as_serializable_dict(self) -> dict: ] if not res["shunt"]: del res["shunt"] + + if cls.detachment_is_allowed: + for el in cls.OBJ_SUPPORT_DETACH: + attr_key = f"detach_{el}" + attr_vect = f"_detach_{el}" + xxx_name = getattr(cls, f"name_{el}") + res[attr_key] = {} + vect_ = getattr(self, attr_vect) + if vect_.any(): + res[attr_key] = [str(xxx_name[el]) for el in vect_.nonzero()[0]] + if not res[attr_key]: + del res[attr_key] return res @classmethod @@ -838,6 +910,19 @@ def process_grid2op_compat(cls): if glop_ver < version.parse("1.9.1"): # this feature did not exist before. cls.dim_alerts = 0 + + if glop_ver < cls.MIN_VERSION_DETACH: + # this feature did not exist before. + cls.authorized_keys = copy.deepcopy(cls.authorized_keys) + cls.attr_list_vect = copy.deepcopy(cls.attr_list_vect) + + for el in cls.OBJ_SUPPORT_DETACH: + attr_key = f"detach_{el}" + attr_vect = f"_{attr_key}" + if attr_key in cls.authorized_keys: + cls.authorized_keys.remove(attr_key) + if attr_vect in cls.attr_list_vect: + cls.attr_list_vect.remove(attr_vect) if (cls.n_busbar_per_sub >= 3) or (cls.n_busbar_per_sub == 1): # only relevant for grid2op >= 1.10.0 @@ -846,7 +931,7 @@ def process_grid2op_compat(cls): # if there are only one busbar, the "set_bus" action can still be used # to disconnect the element, this is why it's not removed cls._aux_process_n_busbar_per_sub() - + cls.attr_list_set = copy.deepcopy(cls.attr_list_set) cls.attr_list_set = set(cls.attr_list_vect) @@ -861,6 +946,9 @@ def _reset_modified_flags(self): self._modif_curtailment = False self._modif_alarm = False self._modif_alert = False + self._modif_detach_load = False + self._modif_detach_gen = False + self._modif_detach_storage = False def can_affect_something(self) -> bool: """ @@ -881,28 +969,68 @@ def can_affect_something(self) -> bool: or self._modif_curtailment or self._modif_alarm or self._modif_alert + or self._modif_detach_load + or self._modif_detach_gen + or self._modif_detach_storage ) def _get_array_from_attr_name(self, attr_name): if hasattr(self, attr_name): res = super()._get_array_from_attr_name(attr_name) - else: - if attr_name in self._dict_inj: - res = self._dict_inj[attr_name] - else: - if attr_name == "prod_p" or attr_name == "prod_v": - res = np.full(self.n_gen, fill_value=0.0, dtype=dt_float) - elif attr_name == "load_p" or attr_name == "load_q": - res = np.full(self.n_load, fill_value=0.0, dtype=dt_float) - else: - raise Grid2OpException( - 'Impossible to find the attribute "{}" ' - 'into the BaseAction of type "{}"'.format(attr_name, type(self)) - ) - return res + return res + + if attr_name in self._dict_inj: + res = self._dict_inj[attr_name] + return res + + cls = type(self) + if attr_name == "prod_p" or attr_name == "prod_v": + res = np.full(cls.n_gen, fill_value=np.nan, dtype=dt_float) + return res + if attr_name == "load_p" or attr_name == "load_q": + res = np.full(cls.n_load, fill_value=np.nan, dtype=dt_float) + return res + raise Grid2OpException( + 'Impossible to find the attribute "{}" ' + 'into the BaseAction of type "{}"'.format(attr_name, cls) + ) + def _set_array_from_attr_name(self, allowed_keys, key: str, array_) -> None: + """used for `from_json` please see `_assign_attr_from_name` for from_vect""" + if key in self._dict_inj: + if np.isfinite(array_).any(): + # because it is used elsewhere, to / from json stores also injection even if it's all nan + # so i need to check in this case or if it's a real injection modification + self._dict_inj[key][:] = array_ + self._post_process_from_vect() # set the correct flags + return + + if key == "prod_p" or key == "prod_v" or key == "load_p" or key == "load_q": + if np.isfinite(array_).any(): + # because it is used elsewhere, to / from json stores also injection even if it's all nan + # so i need to check in this case or if it's a real injection modification + self._dict_inj[key] = np.asarray(array_).astype(dt_float) + self._post_process_from_vect() # set the correct flags + return + + if key in allowed_keys: + # regular stuff + super()._set_array_from_attr_name(allowed_keys, key, array_) + self._post_process_from_vect() # set the correct flags + return + + raise Grid2OpException( + 'Impossible to find the attribute "{}" ' + 'into the BaseAction of type "{}"'.format(key, type(self)) + ) + def _post_process_from_vect(self): - self._modif_inj = self._dict_inj != {} + modif_inj = False + if self._dict_inj != {}: + for k, v in self._dict_inj.items(): + if np.isfinite(v).any(): + modif_inj = True + self._modif_inj = modif_inj self._modif_set_bus = (self._set_topo_vect != 0).any() self._modif_change_bus = (self._change_bus_vect).any() self._modif_set_status = (self._set_line_status != 0).any() @@ -914,8 +1042,13 @@ def _post_process_from_vect(self): self._modif_curtailment = (np.abs(self._curtail + 1.0) >= 1e-7).any() self._modif_alarm = self._raise_alarm.any() self._modif_alert = self._raise_alert.any() + if type(self).detachment_is_allowed: + self._modif_detach_load = (self._detach_load).any() + self._modif_detach_gen = (self._detach_gen).any() + self._modif_detach_storage = (self._detach_storage).any() def _assign_attr_from_name(self, attr_nm, vect): + """used for from_vect, for from_json please see `_set_array_from_attr_name`""" if hasattr(self, attr_nm): if attr_nm not in type(self).attr_list_set: raise AmbiguousAction( @@ -924,8 +1057,14 @@ def _assign_attr_from_name(self, attr_nm, vect): super()._assign_attr_from_name(attr_nm, vect) self._post_process_from_vect() else: + if attr_nm != "load_p" and attr_nm != "load_q" and attr_nm != "prod_p" and attr_nm != "prod_v": + raise AmbiguousAction(f"Unknown action attribute with name {attr_nm}") if np.isfinite(vect).any() and (np.abs(vect) >= 1e-7).any(): - self._dict_inj[attr_nm] = vect + if attr_nm in self._dict_inj: + self._dict_inj[attr_nm][:] = vect + else: + self._dict_inj[attr_nm] = vect.astype(dt_float) + self._post_process_from_vect() def check_space_legit(self): """ @@ -987,8 +1126,131 @@ def get_change_line_status_vect(self) -> np.ndarray: """ return np.full(shape=self.n_line, fill_value=False, dtype=dt_bool) - - def __eq__(self, other) -> bool: + + def _aux_eq_detachment_aux_both_ok(self, other, el_nm: Literal["load", "gen", "storage"]) -> bool: + attr_chgt = f"_modif_detach_{el_nm}" + attr_vect = f"_detach_{el_nm}" + # what I want to do: + # if ((self._modif_detach_load != other._modif_detach_load) or + # (self._detach_load != other._detach_load).any() + # ): + # return False + # but for all attribute related to "detach" feature + + if ((getattr(self, attr_chgt) != getattr(other, attr_chgt)) or + (getattr(self, attr_vect) != getattr(other, attr_vect)).any() + ): + return False + return True + + def _aux_eq_detachment_aux_one_not_ok(self, obj_detach_unsupported, el_nm: Literal["load", "gen", "storage"]) -> bool: + # self supports detachment but not other + # they are equal if an only if self did not + # modify any loads with detachment + # if self._modif_detach_load: + # return False + # if self._detach_load.any(): + # return False + attr_chgt = f"_modif_detach_{el_nm}" + attr_vect = f"_detach_{el_nm}" + if getattr(obj_detach_unsupported, attr_chgt): + return False + if getattr(obj_detach_unsupported, attr_vect): + return False + return True + + def _aux_eq_detachment(self, other: "BaseAction") -> bool: + cls = type(self) + cls_oth = type(other) + if cls.detachment_is_allowed: + if cls_oth.detachment_is_allowed: + # easy case, both detachement allowed + for el in cls.OBJ_SUPPORT_DETACH: + if not self._aux_eq_detachment_aux_both_ok(other, el): + return False + else: + # self supports detachment but not other + # they are equal if an only if self did not + # modify any loads with detachment + for el in cls.OBJ_SUPPORT_DETACH: + if not self._aux_eq_detachment_aux_one_not_ok(self, el): + return False + else: + # detachment is not allowed on self + # check if it's allowed on other + if cls_oth.detachment_is_allowed: + # hard case, I don't support detachment, but + # oth does. + # they can be equal if oth does not modify this + # attribute + for el in cls.OBJ_SUPPORT_DETACH: + if not self._aux_eq_detachment_aux_one_not_ok(other, el): + return False + else: + # if None support detachment, they are both equal concerning the detachment + return True + return True + + def _aux_eq_shunts(self, other: "BaseAction") -> bool: + if type(self).shunts_data_available: + if self.n_shunt != other.n_shunt: + return False + is_ok_me = np.isfinite(self.shunt_p) + is_ok_ot = np.isfinite(other.shunt_p) + if (is_ok_me != is_ok_ot).any(): + return False + if not (self.shunt_p[is_ok_me] == other.shunt_p[is_ok_ot]).all(): + return False + is_ok_me = np.isfinite(self.shunt_q) + is_ok_ot = np.isfinite(other.shunt_q) + if (is_ok_me != is_ok_ot).any(): + return False + if not (self.shunt_q[is_ok_me] == other.shunt_q[is_ok_ot]).all(): + return False + if not (self.shunt_bus == other.shunt_bus).all(): + return False + return True + + def _aux_eq_compare_vect(self, other, modif_flag_nm, vect_nm): + """Implement something similar to : + + ((self._modif_set_status != other._modif_set_status) or + not np.all(self._set_line_status == other._set_line_status) + ) + But for different flag (*eg* `_modif_set_status`) and vector (*eg* `_set_line_status`) + """ + return ((getattr(self, modif_flag_nm) != getattr(other, modif_flag_nm)) or + not np.array_equal(getattr(self, vect_nm), getattr(other, vect_nm)) + ) + + def _aux_eq_inj(self, other: "BaseAction"): + # mismatch in flags + same_action = self._modif_inj == other._modif_inj + if not same_action: + return False + + # all injections are the same + for el in other._dict_inj.keys(): + if el not in self._dict_inj: + # other modify "el" but not "self" + return False + # all injections are the same + for el in self._dict_inj.keys(): + if el not in other._dict_inj: + # "self" modify "el" but not "other" + return False + for el in self._dict_inj.keys(): + me_inj = self._dict_inj[el] + other_inj = other._dict_inj[el] + tmp_me = np.isfinite(me_inj) + tmp_other = np.isfinite(other_inj) + if not np.all(tmp_me == tmp_other) or not np.all( + me_inj[tmp_me] == other_inj[tmp_other] + ): + return False + return True + + def __eq__(self, other: "BaseAction") -> bool: """ Test the equality of two actions. @@ -1022,44 +1284,24 @@ def __eq__(self, other) -> bool: """ if other is None: return False - + cls = type(self) # check that the underlying grid is the same in both instances - same_grid = type(self).same_grid_class(type(other)) + same_grid = cls.same_grid_class(type(other)) if not same_grid: return False # _grid is the same, now I test the the injections modifications are the same - same_action = self._modif_inj == other._modif_inj - same_action = same_action and self._dict_inj.keys() == other._dict_inj.keys() - if not same_action: + if not self._aux_eq_inj(other): return False - - # all injections are the same - for el in self._dict_inj.keys(): - me_inj = self._dict_inj[el] - other_inj = other._dict_inj[el] - tmp_me = np.isfinite(me_inj) - tmp_other = np.isfinite(other_inj) - if not np.all(tmp_me == tmp_other) or not np.all( - me_inj[tmp_me] == other_inj[tmp_other] - ): - return False - + # same line status - if (self._modif_set_status != other._modif_set_status) or not np.all( - self._set_line_status == other._set_line_status - ): + if self._aux_eq_compare_vect(other, "_modif_set_status", "_set_line_status"): return False - - if (self._modif_change_status != other._modif_change_status) or not np.all( - self._switch_line_status == other._switch_line_status - ): + if self._aux_eq_compare_vect(other, "_modif_change_status", "_switch_line_status"): return False # redispatching is same - if (self._modif_redispatch != other._modif_redispatch) or not np.all( - self._redispatch == other._redispatch - ): + if self._aux_eq_compare_vect(other, "_modif_redispatch", "_redispatch"): return False # storage is same @@ -1073,52 +1315,31 @@ def __eq__(self, other) -> bool: return False # curtailment - if (self._modif_curtailment != other._modif_curtailment) or not np.array_equal( - self._curtail, other._curtail - ): + if self._aux_eq_compare_vect(other, "_modif_curtailment", "_curtail"): return False # alarm - if (self._modif_alarm != other._modif_alarm) or not np.array_equal( - self._raise_alarm, other._raise_alarm - ): + if self._aux_eq_compare_vect(other, "_modif_alarm", "_raise_alarm"): return False - # alarm - if (self._modif_alert != other._modif_alert) or not np.array_equal( - self._raise_alert, other._raise_alert - ): + # alert + if self._aux_eq_compare_vect(other, "_modif_alert", "_raise_alert"): return False # same topology changes - if (self._modif_set_bus != other._modif_set_bus) or not np.all( - self._set_topo_vect == other._set_topo_vect - ): + if self._aux_eq_compare_vect(other, "_modif_set_bus", "_set_topo_vect"): return False - if (self._modif_change_bus != other._modif_change_bus) or not np.all( - self._change_bus_vect == other._change_bus_vect - ): + if self._aux_eq_compare_vect(other, "_modif_change_bus", "_change_bus_vect"): return False - + + # handle detachment + if not self._aux_eq_detachment(other): + return False + # shunts are the same - if type(self).shunts_data_available: - if self.n_shunt != other.n_shunt: - return False - is_ok_me = np.isfinite(self.shunt_p) - is_ok_ot = np.isfinite(other.shunt_p) - if (is_ok_me != is_ok_ot).any(): - return False - if not (self.shunt_p[is_ok_me] == other.shunt_p[is_ok_ot]).all(): - return False - is_ok_me = np.isfinite(self.shunt_q) - is_ok_ot = np.isfinite(other.shunt_q) - if (is_ok_me != is_ok_ot).any(): - return False - if not (self.shunt_q[is_ok_me] == other.shunt_q[is_ok_ot]).all(): - return False - if not (self.shunt_bus == other.shunt_bus).all(): - return False - + if not self._aux_eq_shunts(other): + return False + return True def _dont_affect_topology(self) -> bool: @@ -1127,13 +1348,21 @@ def _dont_affect_topology(self) -> bool: and (not self._modif_change_bus) and (not self._modif_set_status) and (not self._modif_change_status) + and (not self._modif_detach_load) + and (not self._modif_detach_gen) + and (not self._modif_detach_storage) ) - def get_topological_impact(self, powerline_status=None) -> Tuple[np.ndarray, np.ndarray]: + def get_topological_impact(self, + powerline_status : Optional[np.ndarray]=None, + _store_in_cache : bool =False, + _read_from_cache : bool =True) -> Tuple[np.ndarray, np.ndarray]: """ Gives information about the element being impacted by this action. + **NB** The impacted elements can be used by :class:`grid2op.BaseRules` to determine whether or not an action is legal or not. + **NB** The impacted are the elements that can potentially be impacted by the action. This does not mean they will be impacted. For examples: @@ -1153,6 +1382,41 @@ def get_topological_impact(self, powerline_status=None) -> Tuple[np.ndarray, np. Any such "change" that would be illegal is declared as "illegal" regardless of the real impact of this action on the powergrid. + Parameters + ----------- + powerline_status: Optional[np.ndarray] + The impact of a powerline can change depending on the status (connected or disconnected) of + the powerlines of the grid (see section :ref:`action_powerline_status` of the documentation). + This argument gives this information to this function. It should be read from the current observation. + + _store_in_cache: ``bool`` + Whether to store the result of this processing in a cache. This is for example used by the + :class:`grid2op.Environment.Environment` especially in the :func:`grid2op.Environment.BaseEnv.step` + to avoid to compute this result over and over again. + + By default its ``False`` and we don't recommend to set it to ``True``. Indeed, if set to ``True`` + then the argument `powerline_status` might be ignored in future calls where `_read_from_cache` is + ``True`` + + .. newinversion:: 1.11.0 + + .. warning:: + Use with extra care, it's private API. + + _read_from_cache: ``bool`` + Whether to read from the cache. + + If the cache has been set by a previous calls to this same function + by explicitly setting `_store_in_cache = True` it will skip all the computation and returns the + values stored in the cache, *de facto* ignoring the argument `powerline_status`. + + If the cache has not been set up then this has no effect (which is the default behaviour). + + By default it's ``True``, but by default no cache is not set up. This means that by default + the argument `powerline_status` is in fact used. + + .. newinversion:: 1.11.0 + Returns ------- lines_impacted: :class:`numpy.ndarray`, dtype:dt_bool @@ -1176,35 +1440,49 @@ def get_topological_impact(self, powerline_status=None) -> Tuple[np.ndarray, np. env_name = "l2rpn_case14_sandbox" # or any other name env = grid2op.make(env_name) + obs = env.reset() # get an action action = env.action_space.sample() # inspect its impact - lines_impacted, subs_impacted = action.get_topological_impact() + lines_impacted, subs_impacted = action.get_topological_impact(obs.line_status) for line_id in np.where(lines_impacted)[0]: print(f"The line {env.name_line[line_id]} with id {line_id} is impacted by this action") print(action) + """ + if (_read_from_cache and + self._lines_impacted is not None and + self._subs_impacted is not None): + # cache is set and I ask to read it + # no need to recompute this + return True & self._lines_impacted, True & self._subs_impacted + + cls = type(self) if self._dont_affect_topology(): # action is not impacting the topology # so it does not modified anything concerning the topology - self._lines_impacted = np.full( - shape=self.n_line, fill_value=False, dtype=dt_bool + _lines_impacted = np.full( + shape=cls.n_line, fill_value=False, dtype=dt_bool ) - self._subs_impacted = np.full( - shape=self.sub_info.shape, fill_value=False, dtype=dt_bool + _subs_impacted = np.full( + shape=cls.n_sub, fill_value=False, dtype=dt_bool ) - return self._lines_impacted, self._subs_impacted + if _store_in_cache: + # store the result in cache is asked too + self._lines_impacted = _lines_impacted + self._subs_impacted = _subs_impacted + return _lines_impacted, _subs_impacted if powerline_status is None: - isnotconnected = np.full(self.n_line, fill_value=True, dtype=dt_bool) + isnotconnected = np.full(cls.n_line, fill_value=True, dtype=dt_bool) else: isnotconnected = ~powerline_status - self._lines_impacted = self._switch_line_status | (self._set_line_status != 0) - self._subs_impacted = np.full( - shape=self.sub_info.shape, fill_value=False, dtype=dt_bool + _lines_impacted = self._switch_line_status | (self._set_line_status != 0) + _subs_impacted = np.full( + shape=cls.n_sub, fill_value=False, dtype=dt_bool ) # compute the changes of the topo vector @@ -1212,10 +1490,10 @@ def get_topological_impact(self, powerline_status=None) -> Tuple[np.ndarray, np. # remove the change due to powerline only effective_change[ - self.line_or_pos_topo_vect[self._lines_impacted & isnotconnected] + self.line_or_pos_topo_vect[_lines_impacted & isnotconnected] ] = False effective_change[ - self.line_ex_pos_topo_vect[self._lines_impacted & isnotconnected] + self.line_ex_pos_topo_vect[_lines_impacted & isnotconnected] ] = False # i can change also the status of a powerline by acting on its extremity @@ -1224,36 +1502,56 @@ def get_topological_impact(self, powerline_status=None) -> Tuple[np.ndarray, np. # if we don't know the state of the grid, we don't consider # these "improvments": we consider a powerline is never # affected if its bus is modified at any of its ends. - connect_set_or = (self._set_topo_vect[self.line_or_pos_topo_vect] > 0) & ( + connect_set_or = (self._set_topo_vect[cls.line_or_pos_topo_vect] > 0) & ( isnotconnected ) - self._lines_impacted |= connect_set_or - effective_change[self.line_or_pos_topo_vect[connect_set_or]] = False - effective_change[self.line_ex_pos_topo_vect[connect_set_or]] = False - connect_set_ex = (self._set_topo_vect[self.line_ex_pos_topo_vect] > 0) & ( + _lines_impacted |= connect_set_or + effective_change[cls.line_or_pos_topo_vect[connect_set_or]] = False + effective_change[cls.line_ex_pos_topo_vect[connect_set_or]] = False + connect_set_ex = (self._set_topo_vect[cls.line_ex_pos_topo_vect] > 0) & ( isnotconnected ) - self._lines_impacted |= connect_set_ex - effective_change[self.line_or_pos_topo_vect[connect_set_ex]] = False - effective_change[self.line_ex_pos_topo_vect[connect_set_ex]] = False + _lines_impacted |= connect_set_ex + effective_change[cls.line_or_pos_topo_vect[connect_set_ex]] = False + effective_change[cls.line_ex_pos_topo_vect[connect_set_ex]] = False # second sub case i disconnected the powerline by setting origin or extremity to negative stuff - disco_set_or = (self._set_topo_vect[self.line_or_pos_topo_vect] < 0) & ( + disco_set_or = (self._set_topo_vect[cls.line_or_pos_topo_vect] < 0) & ( powerline_status ) - self._lines_impacted |= disco_set_or - effective_change[self.line_or_pos_topo_vect[disco_set_or]] = False - effective_change[self.line_ex_pos_topo_vect[disco_set_or]] = False - disco_set_ex = (self._set_topo_vect[self.line_ex_pos_topo_vect] < 0) & ( + _lines_impacted |= disco_set_or + effective_change[cls.line_or_pos_topo_vect[disco_set_or]] = False + effective_change[cls.line_ex_pos_topo_vect[disco_set_or]] = False + disco_set_ex = (self._set_topo_vect[cls.line_ex_pos_topo_vect] < 0) & ( powerline_status ) - self._lines_impacted |= disco_set_ex - effective_change[self.line_or_pos_topo_vect[disco_set_ex]] = False - effective_change[self.line_ex_pos_topo_vect[disco_set_ex]] = False + _lines_impacted |= disco_set_ex + effective_change[cls.line_or_pos_topo_vect[disco_set_ex]] = False + effective_change[cls.line_ex_pos_topo_vect[disco_set_ex]] = False + + _subs_impacted[cls._topo_vect_to_sub[effective_change]] = True + + if cls.detachment_is_allowed: + # added for detachment: it can also affect substations + _subs_impacted[cls.load_to_subid[self._detach_load]] = True + _subs_impacted[cls.gen_to_subid[self._detach_gen]] = True + _subs_impacted[cls.storage_to_subid[self._detach_storage]] = True + + if _store_in_cache: + # store the results in cache if asked too + self._lines_impacted = _lines_impacted + self._subs_impacted = _subs_impacted + return _lines_impacted, _subs_impacted + + def reset_cache_topological_impact(self) -> None: + """INTERNAL + + .. versionadded:: 1.11.0 + + """ + self._lines_impacted = None + self._subs_impacted = None - self._subs_impacted[self._topo_vect_to_sub[effective_change]] = True - return self._lines_impacted, self._subs_impacted - def remove_line_status_from_topo(self, obs: "grid2op.Observation.BaseObservation" = None, check_cooldown: bool = True): @@ -1598,6 +1896,9 @@ def _aux_iadd_modif_flags(self, other): self._modif_curtailment = self._modif_curtailment or other._modif_curtailment self._modif_alarm = self._modif_alarm or other._modif_alarm self._modif_alert = self._modif_alert or other._modif_alert + self._modif_detach_load = self._modif_detach_load or other._modif_detach_load + self._modif_detach_gen = self._modif_detach_gen or other._modif_detach_gen + self._modif_detach_storage = self._modif_detach_storage or other._modif_detach_storage def _aux_iadd_shunt(self, other): if not type(other).shunts_data_available: @@ -1946,10 +2247,10 @@ def _digest_injection(self, dict_): def _digest_setbus(self, dict_): if "set_bus" in dict_: - self._modif_set_bus = True if dict_["set_bus"] is None: # no real action has been made return + self._modif_set_bus = True if isinstance(dict_["set_bus"], dict): ddict_ = dict_["set_bus"] @@ -1989,10 +2290,10 @@ def _digest_setbus(self, dict_): def _digest_change_bus(self, dict_): if "change_bus" in dict_: - self._modif_change_bus = True if dict_["change_bus"] is None: # no real action has been made return + self._modif_change_bus = True if isinstance(dict_["change_bus"], dict): ddict_ = dict_["change_bus"] @@ -2107,6 +2408,12 @@ def _digest_change_status(self, dict_): if dict_["change_line_status"] is not None: self.line_change_status = dict_["change_line_status"] + def _digest_detach_eltype(self, el : Literal["load", "gen", "storage"], dict_): + attr_key = f'detach_{el}' + if attr_key in dict_ and dict_[attr_key] is not None: + setattr(self, attr_key, dict_[attr_key]) + # eg self.detach_load = dict_["detach_load"] + def _digest_redispatching(self, dict_): if "redispatch" in dict_: self.redispatch = dict_["redispatch"] @@ -2163,6 +2470,34 @@ def _reset_vect(self): self._vectorized = None self._subs_impacted = None self._lines_impacted = None + + @staticmethod + def _check_keys_exist(action_cls:GridObjects, act_dict): + """ + Checks whether an action has the same keys in its + action space as are present in the provided dictionary. + + Args: + action_cls (GridObjects): A Grid2Op action + act_dict (str:Any): Dictionary representation of an action + """ + for kk in act_dict.keys(): + if kk not in action_cls.authorized_keys: + if kk == "shunt" and not action_cls.shunts_data_available: + # no warnings are raised in this case because if a warning + # were raised it could crash some environment + # with shunt in "init_state.json" with a backend that does not + # handle shunt + continue + if kk == "set_storage" and action_cls.n_storage == 0: + # no warnings are raised in this case because if a warning + # were raised it could crash some environment + # with storage in "init_state.json" but if the backend did not + # handle storage units + continue + warnings.warn( + f"The key '{kk}' used to update an action will be ignored. Valid keys are {action_cls.authorized_keys}" + ) def update(self, dict_: DICT_ACT_TYPING @@ -2257,6 +2592,9 @@ def update(self, - "curtail" : TODO - "raise_alarm" : TODO - "raise_alert": TODO + - "detach_load": TODO + - "detach_gen": TODO + - "detach_storage": TODO - "shunt": TODO **NB**: CHANGES: you can reconnect a powerline without specifying on each bus you reconnect it at both its @@ -2374,23 +2712,7 @@ def update(self, cls = type(self) if dict_ is not None: - for kk in dict_.keys(): - if kk not in cls.authorized_keys: - if kk == "shunt" and not cls.shunts_data_available: - # no warnings are raised in this case because if a warning - # were raised it could crash some environment - # with shunt in "init_state.json" with a backend that does not - # handle shunt - continue - if kk == "set_storage" and cls.n_storage == 0: - # no warnings are raised in this case because if a warning - # were raised it could crash some environment - # with storage in "init_state.json" but if the backend did not - # handle storage units - continue - warn = 'The key "{}" used to update an action will be ignored. Valid keys are {}' - warn = warn.format(kk, cls.authorized_keys) - warnings.warn(warn) + BaseAction._check_keys_exist(cls, dict_) if cls.shunts_data_available: # do not digest shunt when backend does not support it @@ -2410,7 +2732,9 @@ def update(self, self._digest_change_status(dict_) self._digest_alarm(dict_) self._digest_alert(dict_) - + if cls.detachment_is_allowed: + for el in cls.OBJ_SUPPORT_DETACH: + self._digest_detach_eltype(el, dict_) return self def is_ambiguous(self) -> Tuple[bool, AmbiguousAction]: @@ -2436,13 +2760,14 @@ def is_ambiguous(self) -> Tuple[bool, AmbiguousAction]: return res, info def _check_for_correct_modif_flags(self): + cls = type(self) if self._dict_inj: if not self._modif_inj: raise AmbiguousAction( "A action on the injection is performed while the appropriate flag is not " "set. Please use the official grid2op action API to modify the injections." ) - if "injection" not in self.authorized_keys: + if "injection" not in cls.authorized_keys: raise IllegalAction("You illegally act on the injection") if self._change_bus_vect.any(): if not self._modif_change_bus: @@ -2451,7 +2776,7 @@ def _check_for_correct_modif_flags(self): "set. Please use the official grid2op action API to modify the bus using " "'change'." ) - if "change_bus" not in self.authorized_keys: + if "change_bus" not in cls.authorized_keys: raise IllegalAction("You illegally act on the bus (using change)") if (self._set_topo_vect != 0).any(): if not self._modif_set_bus: @@ -2460,7 +2785,7 @@ def _check_for_correct_modif_flags(self): "set. Please use the official grid2op action API to modify the bus using " "'set'." ) - if "set_bus" not in self.authorized_keys: + if "set_bus" not in cls.authorized_keys: raise IllegalAction("You illegally act on the bus (using set)") if (self._set_line_status != 0).any(): @@ -2471,7 +2796,7 @@ def _check_for_correct_modif_flags(self): "powerline using " "'set'." ) - if "set_line_status" not in self.authorized_keys: + if "set_line_status" not in cls.authorized_keys: raise IllegalAction( "You illegally act on the powerline status (using set)" ) @@ -2484,7 +2809,7 @@ def _check_for_correct_modif_flags(self): "set. Please use the official grid2op action API to modify the status of " "powerlines using 'change'." ) - if "change_line_status" not in self.authorized_keys: + if "change_line_status" not in cls.authorized_keys: raise IllegalAction( "You illegally act on the powerline status (using change)" ) @@ -2497,7 +2822,7 @@ def _check_for_correct_modif_flags(self): "set. Please use the official grid2op action API to perform redispatching " "action." ) - if "redispatch" not in self.authorized_keys: + if "redispatch" not in cls.authorized_keys: raise IllegalAction("You illegally act on the redispatching") if (np.abs(self._storage_power) >= 1e-7).any(): @@ -2508,7 +2833,7 @@ def _check_for_correct_modif_flags(self): "set. Please use the official grid2op action API to perform " "action on storage unit." ) - if "set_storage" not in self.authorized_keys: + if "set_storage" not in cls.authorized_keys: raise IllegalAction("You illegally act on the storage unit") if (np.abs(self._curtail + 1.0) >= 1e-7).any(): @@ -2517,7 +2842,7 @@ def _check_for_correct_modif_flags(self): "A curtailment is performed while the action is not supposed to have done so. " "Please use the official grid2op action API to perform curtailment action." ) - if "curtail" not in self.authorized_keys: + if "curtail" not in cls.authorized_keys: raise IllegalAction("You illegally act on the curtailment") if (self._raise_alarm).any(): @@ -2526,7 +2851,7 @@ def _check_for_correct_modif_flags(self): "Incorrect way to raise some alarm, the appropriate flag is not " "modified properly." ) - if "raise_alarm" not in self.authorized_keys: + if "raise_alarm" not in cls.authorized_keys: raise IllegalAction("You illegally send an alarm.") if (self._raise_alert).any(): @@ -2535,9 +2860,23 @@ def _check_for_correct_modif_flags(self): "Incorrect way to raise some alert, the appropriate flag is not " "modified properly." ) - if "raise_alert" not in self.authorized_keys: + if "raise_alert" not in cls.authorized_keys: raise IllegalAction("You illegally send an alert.") - + + if cls.detachment_is_allowed: + for el in cls.OBJ_SUPPORT_DETACH: + attr_auth = f"detach_{el}" + attr_modif = f"_modif_detach_{el}" + attr_vect = f"_detach_{el}" + if (getattr(self, attr_vect)).any(): + if not getattr(self, attr_modif): + raise AmbiguousAction( + f"Incorrect way to detach some {el}, the appropriate flag is not " + f"modified properly." + ) + if attr_auth not in self.authorized_keys: + raise IllegalAction(f"You illegally detached a {el}.") + def _check_for_ambiguity(self): """ This method checks if an action is ambiguous or not. If the instance is ambiguous, an @@ -2731,22 +3070,6 @@ def _check_for_ambiguity(self): "request on github if you need this feature." ) - if False: - # TODO find an elegant way to disable that - # now it's possible. - for q_id, status in enumerate(self._set_line_status): - if status == 1: - # i reconnect a powerline, i need to check that it's connected on both ends - if ( - self._set_topo_vect[self.line_or_pos_topo_vect[q_id]] == 0 - or self._set_topo_vect[self.line_ex_pos_topo_vect[q_id]] == 0 - ): - - raise InvalidLineStatus( - "You ask to reconnect powerline {} yet didn't tell on" - " which bus.".format(q_id) - ) - if self._modif_set_bus: disco_or = self._set_topo_vect[cls.line_or_pos_topo_vect] == -1 if (self._set_topo_vect[cls.line_ex_pos_topo_vect][disco_or] > 0).any(): @@ -2884,6 +3207,55 @@ def _check_for_ambiguity(self): "as doing so. Expect wrong behaviour." ) + self._is_detachment_ambiguous() + + def _is_detachment_ambiguous(self): + """check if any of the detachment action is ambiguous""" + cls = type(self) + if not cls.detachment_is_allowed: + # detachment is not allowed + if self._modif_detach_gen: + raise IllegalAction("Generators cannot be detached with this environment") + if self._modif_detach_load: + raise IllegalAction("Loads cannot be detached with this environment") + if self._modif_detach_storage: + raise IllegalAction("Storage units cannot be detached with this environment") + if self._detach_gen is not None: + raise IllegalAction("Generators cannot be detached with this environment") + if self._detach_load is not None: + raise IllegalAction("Loads cannot be detached with this environment") + if self._detach_storage is not None: + raise IllegalAction("Storage units cannot be detached with this environment") + return + + # here detachment is allowed, I check consistency between everything + if (not self._modif_detach_gen) and self._detach_gen.any(): + raise AmbiguousAction("Invalid flag for gen detachment, please use standard grid2op API for action.") + if (not self._modif_detach_load) and self._detach_load.any(): + raise AmbiguousAction("Invalid flag for load detachment, please use standard grid2op API for action.") + if (not self._modif_detach_storage) and self._detach_storage.any(): + raise AmbiguousAction("Invalid flag for load detachment, please use standard grid2op API for action.") + if self._modif_detach_load and "detach_load" not in cls.authorized_keys: + raise IllegalAction("It's forbidden to do a load detachment with this action type") + if self._modif_detach_gen and "detach_gen" not in cls.authorized_keys: + raise IllegalAction("It's forbidden to do a generator detachment with this action type") + if self._modif_detach_storage and "detach_storage" not in cls.authorized_keys: + raise IllegalAction("It's forbidden to do a storage detachment with this action type") + for el_nm in cls.OBJ_SUPPORT_DETACH: + _modif_detach_xxx = getattr(self, f"_modif_detach_{el_nm}") + xxx_pos_topo_vect = getattr(cls, f"{el_nm}_pos_topo_vect") + _detach_xxx = getattr(self, f"_detach_{el_nm}") + name_xxx = getattr(cls, f"name_{el_nm}") + if _modif_detach_xxx: + issue_xxx = self._change_bus_vect[xxx_pos_topo_vect] & _detach_xxx + if (issue_xxx).any(): + raise AmbiguousAction(f"Trying to both change a {el_nm} of busbar (change_bus) AND detach it from the grid. " + f"Check {el_nm}: {name_xxx[issue_xxx]}") + issue_xxx = (self._set_topo_vect[xxx_pos_topo_vect] >= 1) & _detach_xxx + if (issue_xxx).any(): + raise AmbiguousAction(f"Trying to both set a {el_nm} of busbar (set_bus) AND detach it from the grid. " + f"Check {el_nm}: {name_xxx[issue_xxx]}") + def _is_storage_ambiguous(self): """check if storage actions are ambiguous""" cls = type(self) @@ -4902,14 +5274,19 @@ def change_bus(self) -> np.ndarray: @change_bus.setter def change_bus(self, values): + cls = type(self) + if "change_bus" not in cls.authorized_keys: + raise IllegalAction( + 'Impossible to modify an element with "change_bus" with this action type.' + ) orig_ = self.change_bus try: self._aux_affect_object_bool( values, "", - self.dim_topo, + cls.dim_topo, None, - np.arange(self.dim_topo), + np.arange(cls.dim_topo), self._change_bus_vect, ) self._modif_change_bus = True @@ -4917,16 +5294,16 @@ def change_bus(self, values): self._aux_affect_object_bool( orig_, "", - self.dim_topo, + cls.dim_topo, None, - np.arange(self.dim_topo), + np.arange(cls.dim_topo), self._change_bus_vect, ) raise IllegalAction( f"Impossible to modify the bus with your input. " f"Please consult the documentation. " - f'The error was:\n"{exc_}"' - ) + f'The error was:\n"{exc_}"' + ) from exc_ @property def load_change_bus(self) -> np.ndarray: @@ -4941,7 +5318,8 @@ def load_change_bus(self) -> np.ndarray: @load_change_bus.setter def load_change_bus(self, values): - if "change_bus" not in self.authorized_keys: + cls = type(self) + if "change_bus" not in cls.authorized_keys: raise IllegalAction( 'Impossible to modify the load bus (with "change") with this action type.' ) @@ -4950,19 +5328,19 @@ def load_change_bus(self, values): self._aux_affect_object_bool( values, "load", - self.n_load, - self.name_load, - self.load_pos_topo_vect, + cls.n_load, + cls.name_load, + cls.load_pos_topo_vect, self._change_bus_vect, _nm_ch_bk_key="loads", ) self._modif_change_bus = True except Exception as exc_: - self._change_bus_vect[self.load_pos_topo_vect] = orig_ + self._change_bus_vect[cls.load_pos_topo_vect] = orig_ raise IllegalAction( f"Impossible to modify the load bus with your input. Please consult the documentation. " f'The error was "{exc_}"' - ) + ) from exc_ @property def gen_change_bus(self) -> np.ndarray: @@ -4976,7 +5354,7 @@ def gen_change_bus(self) -> np.ndarray: each generator units with the convention : * ``False`` this generator is not affected by any "change" action - * ``True`` this generator bus is not affected by any "change" action. If it was + * ``True`` this generator bus is affected by a "change" action. If it was on bus 1, it will be moved to bus 2, if it was on bus 2 it will be moved to bus 1 ( and if it was disconnected it will stay disconnected) @@ -5064,7 +5442,8 @@ def gen_change_bus(self) -> np.ndarray: @gen_change_bus.setter def gen_change_bus(self, values): - if "change_bus" not in self.authorized_keys: + cls = type(self) + if "change_bus" not in cls.authorized_keys: raise IllegalAction( 'Impossible to modify the gen bus (with "change") with this action type.' ) @@ -5073,19 +5452,19 @@ def gen_change_bus(self, values): self._aux_affect_object_bool( values, "gen", - self.n_gen, - self.name_gen, - self.gen_pos_topo_vect, + cls.n_gen, + cls.name_gen, + cls.gen_pos_topo_vect, self._change_bus_vect, _nm_ch_bk_key="prods", ) self._modif_change_bus = True except Exception as exc_: - self._change_bus_vect[self.gen_pos_topo_vect] = orig_ + self._change_bus_vect[cls.gen_pos_topo_vect] = orig_ raise IllegalAction( f"Impossible to modify the gen bus with your input. Please consult the documentation. " f'The error was:\n"{exc_}"' - ) + ) from exc_ @property def storage_change_bus(self) -> np.ndarray: @@ -5100,11 +5479,12 @@ def storage_change_bus(self) -> np.ndarray: @storage_change_bus.setter def storage_change_bus(self, values): - if "change_bus" not in self.authorized_keys: + cls = type(self) + if "change_bus" not in cls.authorized_keys: raise IllegalAction( 'Impossible to modify the storage bus (with "change") with this action type.' ) - if "set_storage" not in self.authorized_keys: + if "set_storage" not in cls.authorized_keys: raise IllegalAction( "Impossible to modify the storage units with this action type." ) @@ -5113,19 +5493,19 @@ def storage_change_bus(self, values): self._aux_affect_object_bool( values, "storage", - self.n_storage, - self.name_storage, - self.storage_pos_topo_vect, + cls.n_storage, + cls.name_storage, + cls.storage_pos_topo_vect, self._change_bus_vect, ) self._modif_change_bus = True except Exception as exc_: - self._change_bus_vect[self.storage_pos_topo_vect] = orig_ + self._change_bus_vect[cls.storage_pos_topo_vect] = orig_ raise IllegalAction( f"Impossible to modify the storage bus with your input. " f"Please consult the documentation. " f'The error was:\n"{exc_}"' - ) + ) from exc_ @property def line_or_change_bus(self) -> np.ndarray: @@ -5140,7 +5520,8 @@ def line_or_change_bus(self) -> np.ndarray: @line_or_change_bus.setter def line_or_change_bus(self, values): - if "change_bus" not in self.authorized_keys: + cls = type(self) + if "change_bus" not in cls.authorized_keys: raise IllegalAction( 'Impossible to modify the line (origin) bus (with "change") with this action type.' ) @@ -5149,20 +5530,20 @@ def line_or_change_bus(self, values): self._aux_affect_object_bool( values, self._line_or_str, - self.n_line, - self.name_line, - self.line_or_pos_topo_vect, + cls.n_line, + cls.name_line, + cls.line_or_pos_topo_vect, self._change_bus_vect, _nm_ch_bk_key="lines", ) self._modif_change_bus = True except Exception as exc_: - self._change_bus_vect[self.line_or_pos_topo_vect] = orig_ + self._change_bus_vect[cls.line_or_pos_topo_vect] = orig_ raise IllegalAction( f"Impossible to modify the line origin bus with your input. " f"Please consult the documentation. " f'The error was:\n"{exc_}"' - ) + ) from exc_ @property def line_ex_change_bus(self) -> np.ndarray: @@ -5177,7 +5558,8 @@ def line_ex_change_bus(self) -> np.ndarray: @line_ex_change_bus.setter def line_ex_change_bus(self, values): - if "change_bus" not in self.authorized_keys: + cls = type(self) + if "change_bus" not in cls.authorized_keys: raise IllegalAction( 'Impossible to modify the line (ex) bus (with "change") with this action type.' ) @@ -5186,20 +5568,20 @@ def line_ex_change_bus(self, values): self._aux_affect_object_bool( values, self._line_ex_str, - self.n_line, - self.name_line, - self.line_ex_pos_topo_vect, + cls.n_line, + cls.name_line, + cls.line_ex_pos_topo_vect, self._change_bus_vect, _nm_ch_bk_key="lines", ) self._modif_change_bus = True except Exception as exc_: - self._change_bus_vect[self.line_ex_pos_topo_vect] = orig_ + self._change_bus_vect[cls.line_ex_pos_topo_vect] = orig_ raise IllegalAction( f"Impossible to modify the line extrmity bus with your input. " f"Please consult the documentation. " f'The error was:\n"{exc_}"' - ) + ) from exc_ @property def line_change_status(self) -> np.ndarray: @@ -5219,7 +5601,8 @@ def line_change_status(self) -> np.ndarray: @line_change_status.setter def line_change_status(self, values): - if "change_line_status" not in self.authorized_keys: + cls = type(self) + if "change_line_status" not in cls.authorized_keys: raise IllegalAction( 'Impossible to modify the status of powerlines (with "change") with this action type.' ) @@ -5228,9 +5611,9 @@ def line_change_status(self, values): self._aux_affect_object_bool( values, "line status", - self.n_line, - self.name_line, - np.arange(self.n_line), + cls.n_line, + cls.name_line, + np.arange(cls.n_line), self._switch_line_status, _nm_ch_bk_key="lines", ) @@ -5241,7 +5624,7 @@ def line_change_status(self, values): f"Impossible to modify the line status with your input. " f"Please consult the documentation. " f'The error was:\n"{exc_}"' - ) + ) from exc_ @property def raise_alarm(self) -> np.ndarray: @@ -5284,16 +5667,17 @@ def raise_alarm(self, values): .. warning:: /!\\\\ Only valid with "l2rpn_icaps_2021" environment /!\\\\ """ - if "raise_alarm" not in self.authorized_keys: + cls = type(self) + if "raise_alarm" not in cls.authorized_keys: raise IllegalAction("Impossible to send alarms with this action type.") orig_ = copy.deepcopy(self._raise_alarm) try: self._aux_affect_object_bool( values, "raise alarm", - self.dim_alarms, - self.alarms_area_names, - np.arange(self.dim_alarms), + cls.dim_alarms, + cls.alarms_area_names, + np.arange(cls.dim_alarms), self._raise_alarm, ) self._modif_alarm = True @@ -5303,7 +5687,7 @@ def raise_alarm(self, values): f"Impossible to modify the alarm with your input. " f"Please consult the documentation. " f'The error was:\n"{exc_}"' - ) + ) from exc_ @property def raise_alert(self) -> np.ndarray: @@ -5332,16 +5716,17 @@ def raise_alert(self) -> np.ndarray: @raise_alert.setter def raise_alert(self, values): - if "raise_alert" not in self.authorized_keys: + cls = type(self) + if "raise_alert" not in cls.authorized_keys: raise IllegalAction("Impossible to send alerts with this action type.") orig_ = copy.deepcopy(self._raise_alert) try: self._aux_affect_object_bool( values, "raise alert", - self.dim_alerts, - self.alertable_line_names, - np.arange(self.dim_alerts), + cls.dim_alerts, + cls.alertable_line_names, + np.arange(cls.dim_alerts), self._raise_alert, ) self._modif_alert = True @@ -5351,7 +5736,243 @@ def raise_alert(self, values): f"Impossible to modify the alert with your input. " f"Please consult the documentation. " f'The error was:\n"{exc_}"' + ) from exc_ + + @property + def detach_load(self) -> np.ndarray: + """ + + ..versionadded:: 1.11.0 + + Allows to retrieve (and affect) the status (connected / disconnected) of loads. + + .. note:: + It is only available after grid2op version 1.11.0 and if the backend + allows it. + + Returns + ------- + res: + A vector of bool, of size `act.n_load` indicating whether this load + is detached or not. + + * ``False`` this load is not affected by any "detach" action + * ``True`` this load will be deactivated. + + Examples + -------- + + To retrieve the impact of the action on the storage unit, you can do: + + .. code-block:: python + + detach_load = act.detach_load + + To modify these buses you can do: + + .. code-block:: python + + # create an environment where i can modify everything + import numpy as np + import grid2op + from grid2op.Action import CompleteAction + env = grid2op.make("educ_case14_storage", + test=True, + action_class=CompleteAction, + allow_detachment=True) + + # create an action + act = env.action_space() + + # method 1 : provide the full vector + act.detach_load = np.ones(act.n_load, dtype=bool) + + # method 2: provide the index of the unit you want to modify + act.detach_load = 1 + + # method 3: provide a list of the units you want to modify + act.detach_load = [1, 2] + + # method 4: change the storage unit by their name with a set + act.detach_load = {"load_1_0"} + + .. note:: The "rule of thumb" to modify an object using "change" method it to provide always + the ID of an object. The ID should be an integer (or a name in some cases). It does not + make any sense to provide a "value" associated to an ID: either you change it, or not. + + Notes + ----- + It is a "property", you don't have to use parenthesis to access it: + + .. code-block:: python + + # valid code + gen_buses = act.gen_change_bus + + # invalid code, it will crash, do not run + gen_buses = act.gen_change_bus() + # end do not run + + And neither should you uses parenthesis to modify it: + + .. code-block:: python + + # valid code + act.gen_change_bus = [1, 2, 3] + + # invalid code, it will crash, do not run + act.gen_change_bus([1, 2, 3]) + # end do not run + + Property cannot be set "directly", you have to use the `act.XXX = ..` syntax. For example: + + .. code-block:: python + + # valid code + act.gen_change_bus = [1, 3, 4] + + # invalid code, it will raise an error, and even if it did not it would have not effect + # do not run + act.gen_change_bus[1] = True + # end do not run + + .. note:: Be careful not to mix "change" and "set". For "change" you only need to provide the ID of the elements + you want to change, for "set" you need to provide the ID **AND** where you want to set them. + + """ + res = copy.deepcopy(self._detach_load) + res.flags.writeable = False + return res + + @detach_load.setter + def detach_load(self, values): + cls = type(self) + if "detach_load" not in cls.authorized_keys: + raise IllegalAction("Impossible detach loads with this action type.") + orig_ = self.detach_load + try: + self._aux_affect_object_bool( + values, + "detach loads", + cls.n_load, + cls.name_load, + np.arange(cls.n_load), + self._detach_load, ) + self._modif_detach_load = True + except Exception as exc_: + self._detach_load[:] = orig_ + raise IllegalAction("Impossible to detach a load with your input.") from exc_ + + @property + def detach_gen(self) -> np.ndarray: + """ + + ..versionadded:: 1.11.0 + + Allows to retrieve (and affect) the status (connected / disconnected) of generators. + + .. note:: + It is only available after grid2op version 1.11.0 and if the backend + allows it. + + Returns + ------- + res: + A vector of bool, of size `act.n_gen` indicating whether this generator + is detached or not. + + * ``False`` this generator is not affected by any "detach" action + * ``True`` this generator will be deactivated. + + Examples + -------- + + See examples in the :attr:`BaseAction.detach_load` for more information + + Notes + ----- + See notes in the :attr:`BaseAction.detach_load` for more information + + """ + res = copy.deepcopy(self._detach_gen) + res.flags.writeable = False + return res + + @detach_gen.setter + def detach_gen(self, values): + cls = type(self) + if "detach_gen" not in cls.authorized_keys: + raise IllegalAction("Impossible to detach generator with this action type.") + orig_ = self.detach_gen + try: + self._aux_affect_object_bool( + values, + "detach gens", + cls.n_gen, + cls.name_gen, + np.arange(cls.n_gen), + self._detach_gen, + ) + self._modif_detach_gen = True + except Exception as exc_: + self._detach_gen[:] = orig_ + raise IllegalAction("Impossible to detach a generator with your input.") from exc_ + + @property + def detach_storage(self) -> np.ndarray: + """ + + ..versionadded:: 1.11.0 + + Allows to retrieve (and affect) the status (connected / disconnected) of storage units. + + .. note:: + It is only available after grid2op version 1.11.0 and if the backend + allows it. + + Returns + ------- + res: + A vector of bool, of size `act.n_storage` indicating whether this generator + is detached or not. + + * ``False`` this storage unit is not affected by any "detach" action + * ``True`` this storage unit will be deactivated. + + Examples + -------- + + See examples in the :attr:`BaseAction.detach_load` for more information + + Notes + ----- + See notes in the :attr:`BaseAction.detach_load` for more information + + """ + res = copy.deepcopy(self._detach_storage) + res.flags.writeable = False + return res + + @detach_storage.setter + def detach_storage(self, values): + cls = type(self) + if "detach_storage" not in cls.authorized_keys: + raise IllegalAction("Impossible to detach a storage unit with this action type.") + orig_ = self.detach_storage + try: + self._aux_affect_object_bool( + values, + "detach storage units", + cls.n_storage, + cls.name_storage, + np.arange(cls.n_storage), + self._detach_storage, + ) + self._modif_detach_storage = True + except Exception as exc_: + self._detach_storage[:] = orig_ + raise IllegalAction("Impossible to detach a storage unit with your input.") from exc_ def _aux_affect_object_float( self, @@ -6635,3 +7256,9 @@ def remove_change(self) -> "BaseAction": self._switch_line_status[:] = False self._modif_change_status = False return self + + def has_element_detached(self): + """Return whether or not this action impact some elements with `detach`, for example + `detach_load`, `detach_gen` or `detach_storage` + """ + return self._modif_detach_gen or self._modif_detach_load or self._modif_detach_storage diff --git a/grid2op/Agent/topologyGreedy.py b/grid2op/Agent/topologyGreedy.py index a6f84239..a37492ba 100644 --- a/grid2op/Agent/topologyGreedy.py +++ b/grid2op/Agent/topologyGreedy.py @@ -6,7 +6,7 @@ # SPDX-License-Identifier: MPL-2.0 # This file is part of Grid2Op, Grid2Op a testbed platform to model sequential decision making in power systems. -from typing import List +from typing import List, Optional from grid2op.Observation import BaseObservation from grid2op.Action import BaseAction, ActionSpace from grid2op.Agent.greedyAgent import GreedyAgent @@ -27,7 +27,7 @@ class TopologyGreedy(GreedyAgent): def __init__(self, action_space: ActionSpace, simulated_time_step : int =1): GreedyAgent.__init__(self, action_space, simulated_time_step=simulated_time_step) - self.tested_action : List[BaseAction]= None + self.tested_action : Optional[list[BaseAction]] = None def _get_tested_action(self, observation: BaseObservation) -> List[BaseAction]: if self.tested_action is None: diff --git a/grid2op/Backend/backend.py b/grid2op/Backend/backend.py index 15539fed..24d789f6 100644 --- a/grid2op/Backend/backend.py +++ b/grid2op/Backend/backend.py @@ -16,6 +16,7 @@ import numpy as np import pandas as pd from typing import Tuple, Optional, Any, Dict, Union + try: from typing import Self except ImportError: @@ -34,12 +35,10 @@ DivergingPowerflow, Grid2OpException, ) -from grid2op.Space import GridObjects, DEFAULT_N_BUSBAR_PER_SUB +from grid2op.Space import GridObjects, ElTypeInfo, DEFAULT_N_BUSBAR_PER_SUB, DEFAULT_ALLOW_DETACHMENT # TODO method to get V and theta at each bus, could be in the same shape as check_kirchoff - - class Backend(GridObjects, ABC): """ INTERNAL @@ -117,13 +116,16 @@ class Backend(GridObjects, ABC): IS_BK_CONVERTER : bool = False # action to set me - my_bk_act_class : "Optional[grid2op.Action._backendAction._BackendAction]"= None - _complete_action_class : "Optional[grid2op.Action.CompleteAction]"= None + my_bk_act_class : "Optional[grid2op.Action._backendAction._BackendAction]" = None + _complete_action_class : "Optional[grid2op.Action.CompleteAction]" = None ERR_INIT_POWERFLOW : str = "Power cannot be computed on the first time step, please check your data." + ERR_DETACHMENT : str = ("One or more {} were isolated from the grid " + "but this is not allowed or not supported (Game Over) (detachment_is_allowed is False), " + "check {} {}") def __init__(self, - detailed_infos_for_cascading_failures: bool=False, - can_be_copied: bool=True, + detailed_infos_for_cascading_failures:bool=False, + can_be_copied:bool=True, **kwargs): """ Initialize an instance of Backend. This does nothing per se. Only the call to :func:`Backend.load_grid` @@ -179,6 +181,10 @@ def __init__(self, #: There is a difference between this and the class attribute. #: You should not worry about the class attribute of the backend in :func:`Backend.apply_action` self.n_busbar_per_sub: int = DEFAULT_N_BUSBAR_PER_SUB + + #: .. versionadded: 1.11.0 + self._missing_detachment_support_info : bool = True + self.detachment_is_allowed : bool = DEFAULT_ALLOW_DETACHMENT def can_handle_more_than_2_busbar(self): """ @@ -240,7 +246,65 @@ def cannot_handle_more_than_2_busbar(self): "'fix' this issue, you need to change the implementation of your backend or " "upgrade it to a newer version.") self.n_busbar_per_sub = DEFAULT_N_BUSBAR_PER_SUB - + + + def can_handle_detachment(self): + """ + .. versionadded:: 1.11.0 + + This function should be called once in :func:`Backend.load_grid` if your backend is able + to handle the detachment of loads and generators. + + If not called, then the `environment` will not be able to detach loads and generators. + + .. seealso:: + :func:`Backend.cannot_handle_detachment` + + .. note:: + From grid2op 1.11.0 it is preferable that your backend calls one of + :func:`Backend.can_handle_detachment` or + :func:`Backend.cannot_handle_detachment`. + + If not, then the environments created with your backend will not be able to + "operate" grid with load and generator detachment. + + .. danger:: + We highly recommend you do not try to override this function. + At least, at time of writing there is no good reason to do so. + """ + self._missing_detachment_support_info = False + self.detachment_is_allowed = type(self).detachment_is_allowed + + def cannot_handle_detachment(self): + """ + .. versionadded:: 1.11.0 + + This function should be called once in :func:`Backend.load_grid` if your backend is **NOT** able + to handle the detachment of loads and generators. + + If not called, then the `environment` will not be able to detach loads and generators. + + .. seealso:: + :func:`Backend.cannot_handle_detachment` + + .. note:: + From grid2op 1.11.0 it is preferable that your backend calls one of + :func:`Backend.can_handle_detachment` or + :func:`Backend.cannot_handle_detachment`. + + If not, then the environments created with your backend will not be able to + "operate" grid with load and generator detachment. + + .. danger:: + We highly recommend you do not try to override this function. + At least, at time of writing there is no good reason to do so. + """ + self._missing_detachment_support_info = False + if type(self).detachment_is_allowed != DEFAULT_ALLOW_DETACHMENT: + warnings.warn("You asked in 'make' function to allow shedding. This is" + f"not possible with a backend of type {type(self)}.") + self.detachment_is_allowed = DEFAULT_ALLOW_DETACHMENT + def make_complete_path(self, path : Union[os.PathLike, str], filename : Optional[Union[os.PathLike, str]]=None) -> str: @@ -662,10 +726,10 @@ def get_line_status(self) -> np.ndarray: :return: an array with the line status of each powerline :rtype: np.array, dtype:bool """ + cls = type(self) topo_vect = self.get_topo_vect() - return (topo_vect[self.line_or_pos_topo_vect] >= 0) & ( - topo_vect[self.line_ex_pos_topo_vect] >= 0 - ) + return ((topo_vect[cls.line_or_pos_topo_vect] >= 0) & + (topo_vect[cls.line_ex_pos_topo_vect] >= 0)) def get_line_flow(self) -> np.ndarray: """ @@ -1017,13 +1081,40 @@ def _runpf_with_diverging_exception(self, is_dc : bool) -> Optional[Exception]: """ conv = False exc_me = None + cls = type(self) try: conv, exc_me = self.runpf(is_dc=is_dc) # run powerflow + + if not conv: + if exc_me is not None: + raise exc_me + raise BackendError("Divergence of the powerflow without further information.") + + # Check if loads/gens have been detached and if this is allowed, otherwise raise an error + # .. versionadded:: 1.11.0 + topo_vect = self.get_topo_vect() + load_buses = topo_vect[cls.load_pos_topo_vect] + if not cls.detachment_is_allowed and (load_buses == -1).any(): + raise BackendError(cls.ERR_DETACHMENT.format("loads", "loads", (load_buses == -1).nonzero()[0])) + + gen_buses = topo_vect[cls.gen_pos_topo_vect] + if not cls.detachment_is_allowed and (gen_buses == -1).any(): + raise BackendError(cls.ERR_DETACHMENT.format("gens", "gens", (gen_buses == -1).nonzero()[0])) + + if cls.n_storage > 0: + storage_buses = topo_vect[cls.storage_pos_topo_vect] + storage_p, *_ = self.storages_info() + sto_maybe_error = (storage_buses == -1) & (np.abs(storage_p) >= 1e-6) + if not cls.detachment_is_allowed and sto_maybe_error.any(): + raise BackendError((cls.ERR_DETACHMENT.format("storages", "storages", sto_maybe_error.nonzero()[0]) + + " NB storage units are allowed to be disconnected even if " + "`detachment_is_allowed` is False but only if the don't produce active power.")) + except Grid2OpException as exc_: exc_me = exc_ if not conv and exc_me is None: - exc_me = DivergingPowerflow( + exc_me = BackendError( "GAME OVER: Powerflow has diverged during computation " "or a load has been disconnected or a generator has been disconnected." ) @@ -1135,6 +1226,8 @@ def storages_info(self) -> Tuple[np.ndarray, np.ndarray, np.ndarray]: raise BackendError( "storages_info method is not implemented yet there is batteries on the grid." ) + empty_ = np.array([]) + return empty_, empty_, empty_ def storage_deact_for_backward_comaptibility(self) -> None: """ @@ -1161,7 +1254,7 @@ def check_kirchoff(self) -> Tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray """ warnings.warn(message="please use backend.check_kirchhoff() instead", category=DeprecationWarning) return self.check_kirchhoff() - + def check_kirchhoff(self) -> Tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray, np.ndarray]: """ INTERNAL @@ -1200,225 +1293,58 @@ def check_kirchhoff(self) -> Tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarra - second element represents the busbar in the substation (0 or 1 usually) """ - + cls = type(self) p_or, q_or, v_or, *_ = self.lines_or_info() p_ex, q_ex, v_ex, *_ = self.lines_ex_info() p_gen, q_gen, v_gen = self.generators_info() p_load, q_load, v_load = self.loads_info() - cls = type(self) + topo_vect = self.get_topo_vect() + lineor_info = ElTypeInfo( + topo_vect[cls.line_or_pos_topo_vect], + p_or, + q_or, + v_or, + ) + lineex_info = ElTypeInfo( + topo_vect[cls.line_ex_pos_topo_vect], + p_ex, + q_ex, + v_ex, + ) + load_info = ElTypeInfo( + topo_vect[cls.load_pos_topo_vect], + p_load, + q_load, + v_load, + ) + gen_info = ElTypeInfo( + topo_vect[cls.gen_pos_topo_vect], + p_gen, q_gen, v_gen, + ) if cls.n_storage > 0: p_storage, q_storage, v_storage = self.storages_info() - - # fist check the "substation law" : nothing is created at any substation - p_subs = np.zeros(cls.n_sub, dtype=dt_float) - q_subs = np.zeros(cls.n_sub, dtype=dt_float) - - # check for each bus - p_bus = np.zeros((cls.n_sub, cls.n_busbar_per_sub), dtype=dt_float) - q_bus = np.zeros((cls.n_sub, cls.n_busbar_per_sub), dtype=dt_float) - v_bus = ( - np.zeros((cls.n_sub, cls.n_busbar_per_sub, 2), dtype=dt_float) - 1.0 - ) # sub, busbar, [min,max] - topo_vect = self.get_topo_vect() - - # bellow i'm "forced" to do a loop otherwise, numpy do not compute the "+=" the way I want it to. - # for example, if two powerlines are such that line_or_to_subid is equal (eg both connected to substation 0) - # then numpy do not guarantee that `p_subs[self.line_or_to_subid] += p_or` will add the two "corresponding p_or" - # TODO this can be vectorized with matrix product, see example in obs.flow_bus_matrix (BaseObervation.py) - for i in range(cls.n_line): - sub_or_id = cls.line_or_to_subid[i] - sub_ex_id = cls.line_ex_to_subid[i] - if (topo_vect[cls.line_or_pos_topo_vect[i]] == -1 or - topo_vect[cls.line_ex_pos_topo_vect[i]] == -1): - # line is disconnected - continue - loc_bus_or = topo_vect[cls.line_or_pos_topo_vect[i]] - 1 - loc_bus_ex = topo_vect[cls.line_ex_pos_topo_vect[i]] - 1 - - # for substations - p_subs[sub_or_id] += p_or[i] - p_subs[sub_ex_id] += p_ex[i] - - q_subs[sub_or_id] += q_or[i] - q_subs[sub_ex_id] += q_ex[i] - - # for bus - p_bus[sub_or_id, loc_bus_or] += p_or[i] - q_bus[sub_or_id, loc_bus_or] += q_or[i] - - p_bus[ sub_ex_id, loc_bus_ex] += p_ex[i] - q_bus[sub_ex_id, loc_bus_ex] += q_ex[i] - - # fill the min / max voltage per bus (initialization) - if (v_bus[sub_or_id, loc_bus_or,][0] == -1): - v_bus[sub_or_id, loc_bus_or,][0] = v_or[i] - if (v_bus[sub_ex_id, loc_bus_ex,][0] == -1): - v_bus[sub_ex_id, loc_bus_ex,][0] = v_ex[i] - if (v_bus[sub_or_id, loc_bus_or,][1]== -1): - v_bus[sub_or_id, loc_bus_or,][1] = v_or[i] - if (v_bus[sub_ex_id, loc_bus_ex,][1]== -1): - v_bus[sub_ex_id, loc_bus_ex,][1] = v_ex[i] - - # now compute the correct stuff - if v_or[i] > 0.0: - # line is connected - v_bus[sub_or_id, loc_bus_or,][0] = min(v_bus[sub_or_id, loc_bus_or,][0],v_or[i],) - v_bus[sub_or_id, loc_bus_or,][1] = max(v_bus[sub_or_id, loc_bus_or,][1],v_or[i],) - - if v_ex[i] > 0: - # line is connected - v_bus[sub_ex_id, loc_bus_ex,][0] = min(v_bus[sub_ex_id, loc_bus_ex,][0],v_ex[i],) - v_bus[sub_ex_id, loc_bus_ex,][1] = max(v_bus[sub_ex_id, loc_bus_ex,][1],v_ex[i],) - - for i in range(cls.n_gen): - gptv = cls.gen_pos_topo_vect[i] - - if topo_vect[gptv] == -1: - # gen is disconnected - continue - - # for substations - p_subs[cls.gen_to_subid[i]] -= p_gen[i] - q_subs[cls.gen_to_subid[i]] -= q_gen[i] - - loc_bus = topo_vect[gptv] - 1 - # for bus - p_bus[ - cls.gen_to_subid[i], loc_bus - ] -= p_gen[i] - q_bus[ - cls.gen_to_subid[i], loc_bus - ] -= q_gen[i] - - # compute max and min values - if v_gen[i]: - # but only if gen is connected - v_bus[cls.gen_to_subid[i], loc_bus][ - 0 - ] = min( - v_bus[ - cls.gen_to_subid[i], loc_bus - ][0], - v_gen[i], - ) - v_bus[cls.gen_to_subid[i], loc_bus][ - 1 - ] = max( - v_bus[ - cls.gen_to_subid[i], loc_bus - ][1], - v_gen[i], - ) - - for i in range(cls.n_load): - gptv = cls.load_pos_topo_vect[i] - - if topo_vect[gptv] == -1: - # load is disconnected - continue - loc_bus = topo_vect[gptv] - 1 - - # for substations - p_subs[cls.load_to_subid[i]] += p_load[i] - q_subs[cls.load_to_subid[i]] += q_load[i] - - # for buses - p_bus[ - cls.load_to_subid[i], loc_bus - ] += p_load[i] - q_bus[ - cls.load_to_subid[i], loc_bus - ] += q_load[i] - - # compute max and min values - if v_load[i]: - # but only if load is connected - v_bus[cls.load_to_subid[i], loc_bus][ - 0 - ] = min( - v_bus[ - cls.load_to_subid[i], loc_bus - ][0], - v_load[i], - ) - v_bus[cls.load_to_subid[i], loc_bus][ - 1 - ] = max( - v_bus[ - cls.load_to_subid[i], loc_bus - ][1], - v_load[i], - ) - - for i in range(cls.n_storage): - gptv = cls.storage_pos_topo_vect[i] - if topo_vect[gptv] == -1: - # storage is disconnected - continue - loc_bus = topo_vect[gptv] - 1 - - p_subs[cls.storage_to_subid[i]] += p_storage[i] - q_subs[cls.storage_to_subid[i]] += q_storage[i] - p_bus[ - cls.storage_to_subid[i], loc_bus - ] += p_storage[i] - q_bus[ - cls.storage_to_subid[i], loc_bus - ] += q_storage[i] - - # compute max and min values - if v_storage[i] > 0: - # the storage unit is connected - v_bus[ - cls.storage_to_subid[i], - loc_bus, - ][0] = min( - v_bus[ - cls.storage_to_subid[i], - loc_bus, - ][0], - v_storage[i], - ) - v_bus[ - self.storage_to_subid[i], - loc_bus, - ][1] = max( - v_bus[ - cls.storage_to_subid[i], - loc_bus, - ][1], - v_storage[i], - ) + storage_info = ElTypeInfo( + topo_vect[cls.storage_pos_topo_vect], + p_storage, q_storage, v_storage, + ) + else: + storage_info = None if cls.shunts_data_available: p_s, q_s, v_s, bus_s = self.shunt_info() - for i in range(cls.n_shunt): - if bus_s[i] == -1: - # shunt is disconnected - continue - - # for substations - p_subs[cls.shunt_to_subid[i]] += p_s[i] - q_subs[cls.shunt_to_subid[i]] += q_s[i] - - # for buses - p_bus[cls.shunt_to_subid[i], bus_s[i] - 1] += p_s[i] - q_bus[cls.shunt_to_subid[i], bus_s[i] - 1] += q_s[i] - - # compute max and min values - v_bus[cls.shunt_to_subid[i], bus_s[i] - 1][0] = min( - v_bus[cls.shunt_to_subid[i], bus_s[i] - 1][0], v_s[i] - ) - v_bus[cls.shunt_to_subid[i], bus_s[i] - 1][1] = max( - v_bus[cls.shunt_to_subid[i], bus_s[i] - 1][1], v_s[i] - ) - else: - warnings.warn( - "Backend.check_kirchhoff Impossible to get shunt information. Reactive information might be " - "incorrect." + shunt_info = ElTypeInfo( + bus_s, + p_s, q_s, v_s, ) - diff_v_bus = np.zeros((cls.n_sub, cls.n_busbar_per_sub), dtype=dt_float) - diff_v_bus[:, :] = v_bus[:, :, 1] - v_bus[:, :, 0] + else: + shunt_info = None + + p_subs, q_subs, p_bus, q_bus, diff_v_bus = cls._aux_check_kirchhoff(lineor_info, + lineex_info, + load_info, + gen_info, + storage_info, + shunt_info) return p_subs, q_subs, p_bus, q_bus, diff_v_bus def _fill_names_obj(self): @@ -1956,6 +1882,11 @@ def get_action_to_set(self) -> "grid2op.Action.CompleteAction": ) prod_p, _, prod_v = self.generators_info() load_p, load_q, _ = self.loads_info() + if type(self)._complete_action_class is None: + # some bug in multiprocessing, this was not set + # sub processes + from grid2op.Action.completeAction import CompleteAction + type(self)._complete_action_class = CompleteAction.init_grid(type(self)) set_me = self._complete_action_class() dict_ = { "set_line_status": line_status, @@ -2093,6 +2024,27 @@ def assert_grid_correct(self, _local_dir_cls=None) -> None: warnings.warn("Your backend is missing the `_missing_two_busbars_support_info` " "attribute. This is known issue in lightims2grid <= 0.7.5. Please " "upgrade your backend. This will raise an error in the future.") + + if hasattr(self, "_missing_detachment_support_info"): + if self._missing_detachment_support_info: + warnings.warn("The backend implementation you are using is probably too old to take advantage of the " + "new feature added in grid2op 1.11.0: the possibility " + "to detach loads or generators without leading to an immediate game over. " + "To silence this warning, you can modify the `load_grid` implementation " + "of your backend and either call:\n" + "- self.can_handle_detachment if the current implementation " + " can handle detachments OR\n" + "- self.cannot_handle_detachment if not." + "\nAnd of course, ideally, if the current implementation " + "of your backend cannot handle detachment then change it :-)\n" + "Your backend will behave as if it did not support it.") + self._missing_detachment_support_info = False + self.detachment_is_allowed = DEFAULT_ALLOW_DETACHMENT + else: + self._missing_detachment_support_info = False + self.detachment_is_allowed = DEFAULT_ALLOW_DETACHMENT + warnings.warn("Your backend is missing the `_missing_detachment_support_info` " + "attribute.") orig_type = type(self) if orig_type.my_bk_act_class is None and orig_type._INIT_GRID_CLS is None: diff --git a/grid2op/Backend/pandaPowerBackend.py b/grid2op/Backend/pandaPowerBackend.py index 61062057..b073c16c 100644 --- a/grid2op/Backend/pandaPowerBackend.py +++ b/grid2op/Backend/pandaPowerBackend.py @@ -136,7 +136,7 @@ def __init__( lightsim2grid=lightsim2grid, dist_slack=dist_slack, max_iter=max_iter, - with_numba=with_numba + with_numba=with_numba, ) self.with_numba : bool = with_numba self.prod_pu_to_kv : Optional[np.ndarray] = None @@ -327,7 +327,8 @@ def reset(self, warnings.simplefilter("ignore", FutureWarning) self._grid = copy.deepcopy(self.__pp_backend_initial_grid) self._reset_all_nan() - self._topo_vect[:] = self._get_topo_vect() + self._get_line_status() + self._get_topo_vect() self.comp_time = 0.0 def load_grid(self, @@ -344,6 +345,7 @@ def load_grid(self, """ self.can_handle_more_than_2_busbar() + self.can_handle_detachment() full_path = self.make_complete_path(path, filename) with warnings.catch_warnings(): @@ -541,7 +543,7 @@ def load_grid(self, pp.create_bus(self._grid, index=ind, **el) self._init_private_attrs() self._aux_run_pf_init() # run yet another powerflow with the added buses - + # do this at the end self._in_service_line_col_id = int((self._grid.line.columns == "in_service").nonzero()[0][0]) self._in_service_trafo_col_id = int((self._grid.trafo.columns == "in_service").nonzero()[0][0]) @@ -716,6 +718,8 @@ def _init_private_attrs(self) -> None: ) self._compute_pos_big_topo() + + self._topo_vect = np.full(self.dim_topo, fill_value=-1, dtype=dt_int) # utilities for imeplementing apply_action self._corresp_name_fun = {} @@ -767,7 +771,6 @@ def _init_private_attrs(self) -> None: self.q_ex = np.full(self.n_line, dtype=dt_float, fill_value=np.NaN) self.v_ex = np.full(self.n_line, dtype=dt_float, fill_value=np.NaN) self.a_ex = np.full(self.n_line, dtype=dt_float, fill_value=np.NaN) - self.line_status = np.full(self.n_line, dtype=dt_bool, fill_value=np.NaN) self.load_p = np.full(self.n_load, dtype=dt_float, fill_value=np.NaN) self.load_q = np.full(self.n_load, dtype=dt_float, fill_value=np.NaN) self.load_v = np.full(self.n_load, dtype=dt_float, fill_value=np.NaN) @@ -778,6 +781,9 @@ def _init_private_attrs(self) -> None: self.storage_q = np.full(self.n_storage, dtype=dt_float, fill_value=np.NaN) self.storage_v = np.full(self.n_storage, dtype=dt_float, fill_value=np.NaN) self._nb_bus_before = None + + self.line_status = np.full(self.n_line, dtype=dt_bool, fill_value=np.NaN) + self.line_status.flags.writeable = False # store the topoid -> objid self._init_topoid_objid() @@ -804,7 +810,7 @@ def _init_private_attrs(self) -> None: self.gen_theta = np.full(self.n_gen, fill_value=np.NaN, dtype=dt_float) self.storage_theta = np.full(self.n_storage, fill_value=np.NaN, dtype=dt_float) - self._topo_vect = self._get_topo_vect() + self._get_topo_vect() self.tol = 1e-5 # this is NOT the pandapower tolerance !!!! this is used to check if a storage unit # produce / absorbs anything @@ -823,7 +829,10 @@ def storage_deact_for_backward_comaptibility(self) -> None: self.storage_p = np.full(cls.n_storage, dtype=dt_float, fill_value=np.NaN) self.storage_q = np.full(cls.n_storage, dtype=dt_float, fill_value=np.NaN) self.storage_v = np.full(cls.n_storage, dtype=dt_float, fill_value=np.NaN) - self._topo_vect = self._get_topo_vect() + self._topo_vect.flags.writeable = True + self._topo_vect.resize(cls.dim_topo) + self._topo_vect.flags.writeable = False + self._get_topo_vect() def _convert_id_topo(self, id_big_topo): """ @@ -893,7 +902,6 @@ def apply_action(self, backendAction: Union["grid2op.Action._backendAction._Back tmp_stor_p = self._grid.storage["p_mw"] if (storage.changed).any(): tmp_stor_p.iloc[storage.changed] = storage.values[storage.changed] - # topology of the storage stor_bus = backendAction.get_storages_bus() new_bus_num = dt_int(1) * self._grid.storage["bus"].values @@ -905,11 +913,7 @@ def apply_action(self, backendAction: Union["grid2op.Action._backendAction._Back self._grid.storage.loc[stor_bus.changed & deactivated, "in_service"] = False self._grid.storage.loc[stor_bus.changed & ~deactivated, "in_service"] = True self._grid.storage["bus"] = new_bus_num - self._topo_vect[cls.storage_pos_topo_vect[stor_bus.changed]] = new_bus_id - self._topo_vect[ - cls.storage_pos_topo_vect[deact_and_changed] - ] = -1 - + if type(backendAction).shunts_data_available: shunt_p, shunt_q, shunt_bus = shunts__ @@ -935,6 +939,8 @@ def apply_action(self, backendAction: Union["grid2op.Action._backendAction._Back if type_obj is not None: # storage unit are handled elsewhere self._type_to_bus_set[type_obj](new_bus, id_el_backend, id_topo) + + self._topo_vect.flags.writeable = False def _apply_load_bus(self, new_bus, id_el_backend, id_topo): new_bus_backend = type(self).local_bus_to_global_int( @@ -1035,28 +1041,7 @@ def _aux_runpf_pp(self, is_dc: bool): warnings.filterwarnings("ignore", category=RuntimeWarning) warnings.filterwarnings("ignore", category=DeprecationWarning) self._pf_init = "dc" - # nb_bus = self.get_nb_active_bus() - # if self._nb_bus_before is None: - # self._pf_init = "dc" - # elif nb_bus == self._nb_bus_before: - # self._pf_init = "results" - # else: - # self._pf_init = "auto" - - if (~self._grid.load["in_service"]).any(): - # TODO see if there is a better way here -> do not handle this here, but rather in Backend._next_grid_state - raise pp.powerflow.LoadflowNotConverged("Disconnected load: for now grid2op cannot handle properly" - " disconnected load. If you want to disconnect one, say it" - " consumes 0. instead. Please check loads: " - f"{(~self._grid.load['in_service'].values).nonzero()[0]}" - ) - if (~self._grid.gen["in_service"]).any(): - # TODO see if there is a better way here -> do not handle this here, but rather in Backend._next_grid_state - raise pp.powerflow.LoadflowNotConverged("Disconnected gen: for now grid2op cannot handle properly" - " disconnected generators. If you want to disconnect one, say it" - " produces 0. instead. Please check generators: " - f"{(~self._grid.gen['in_service'].values).nonzero()[0]}" - ) + try: if is_dc: pp.rundcpp(self._grid, check_connectivity=True, init="flat") @@ -1103,7 +1088,14 @@ def runpf(self, is_dc : bool=False) -> Tuple[bool, Union[Exception, None]]: in case of "do nothing" action applied. """ try: + # as pandapower does not modify the topology or the status of + # powerline, then we can compute the topology (and the line status) + # at the beginning + # This is also interesting in case of divergence :-) + self._get_line_status() + self._get_topo_vect() self._aux_runpf_pp(is_dc) + cls = type(self) # if a connected bus has a no voltage, it's a divergence (grid was not connected) if self._grid.res_bus.loc[self._grid.bus["in_service"]]["va_degree"].isnull().any(): @@ -1124,12 +1116,7 @@ def runpf(self, is_dc : bool=False) -> Tuple[bool, Union[Exception, None]]: self.load_theta[:], ) = self._loads_info() - if not is_dc: - if not np.isfinite(self.load_v).all(): - # TODO see if there is a better way here - # some loads are disconnected: it's a game over case! - raise pp.powerflow.LoadflowNotConverged(f"Isolated load: check loads {np.isfinite(self.load_v).nonzero()[0]}") - else: + if is_dc: # fix voltages magnitude that are always "nan" for dc case # self._grid.res_bus["vm_pu"] is always nan when computed in DC self.load_v[:] = self.load_pu_to_kv # TODO @@ -1148,8 +1135,7 @@ def runpf(self, is_dc : bool=False) -> Tuple[bool, Union[Exception, None]]: ): self.load_v[l_id] = self.prod_v[g_id] break - - self.line_status[:] = self._get_line_status() + # I retrieve the data once for the flows, so has to not re read multiple dataFrame self.p_or[:] = self._aux_get_line_info("p_from_mw", "p_hv_mw") self.q_or[:] = self._aux_get_line_info("q_from_mvar", "q_hv_mvar") @@ -1194,17 +1180,12 @@ def runpf(self, is_dc : bool=False) -> Tuple[bool, Union[Exception, None]]: self.storage_v[:], self.storage_theta[:], ) = self._storages_info() + deact_storage = ~np.isfinite(self.storage_v) - if (np.abs(self.storage_p[deact_storage]) > self.tol).any(): - raise pp.powerflow.LoadflowNotConverged( - "Isolated storage set to absorb / produce something" - ) self.storage_p[deact_storage] = 0.0 self.storage_q[deact_storage] = 0.0 self.storage_v[deact_storage] = 0.0 self._grid.storage["in_service"].values[deact_storage] = False - - self._topo_vect[:] = self._get_topo_vect() if not self._grid.converged: raise pp.powerflow.LoadflowNotConverged("Divergence without specific reason (self._grid.converged is False)") self.div_exception = None @@ -1242,7 +1223,12 @@ def _reset_all_nan(self) -> None: self.load_theta[:] = np.NaN self.gen_theta[:] = np.NaN self.storage_theta[:] = np.NaN - + self._topo_vect.flags.writeable = True + self._topo_vect[:] = -1 + self._topo_vect.flags.writeable = False + self.line_status.flags.writeable = True + self.line_status[:] = False + self.line_status.flags.writeable = False def copy(self) -> "PandaPowerBackend": """ INTERNAL @@ -1344,6 +1330,7 @@ def copy(self) -> "PandaPowerBackend": res._in_service_trafo_col_id = self._in_service_trafo_col_id res._missing_two_busbars_support_info = self._missing_two_busbars_support_info + res._missing_detachment_support_info = self._missing_detachment_support_info res.div_exception = self.div_exception return res @@ -1387,12 +1374,15 @@ def get_line_status(self) -> np.ndarray: return self.line_status def _get_line_status(self): - return np.concatenate( + self.line_status.flags.writeable = True + self.line_status[:] = np.concatenate( ( self._grid.line["in_service"].values, self._grid.trafo["in_service"].values, ) ).astype(dt_bool) + self.line_status.flags.writeable = False + return self.line_status def get_line_flow(self) -> np.ndarray: return self.a_or @@ -1402,45 +1392,62 @@ def _disconnect_line(self, id_): self._grid.line.iloc[id_, self._in_service_line_col_id] = False else: self._grid.trafo.iloc[id_ - self._number_true_line, self._in_service_trafo_col_id] = False + self._topo_vect.flags.writeable = True self._topo_vect[self.line_or_pos_topo_vect[id_]] = -1 self._topo_vect[self.line_ex_pos_topo_vect[id_]] = -1 + self._topo_vect.flags.writeable = False + self.line_status.flags.writeable = True self.line_status[id_] = False + self.line_status.flags.writeable = False def _reconnect_line(self, id_): if id_ < self._number_true_line: self._grid.line.iloc[id_, self._in_service_line_col_id] = True else: self._grid.trafo.iloc[id_ - self._number_true_line, self._in_service_trafo_col_id] = True + self.line_status.flags.writeable = True self.line_status[id_] = True + self.line_status.flags.writeable = False def get_topo_vect(self) -> np.ndarray: return self._topo_vect def _get_topo_vect(self): - cls = type(self) - res = np.full(cls.dim_topo, fill_value=np.iinfo(dt_int).max, dtype=dt_int) + """ + .. danger:: + you should have called `self._get_line_status` before otherwise it might + not behave correctly ! + Returns + ------- + _type_ + _description_ + """ + cls = type(self) + # lines / trafo line_status = self.get_line_status() + self._topo_vect.flags.writeable = True glob_bus_or = np.concatenate((self._grid.line["from_bus"].values, self._grid.trafo["hv_bus"].values)) - res[cls.line_or_pos_topo_vect] = cls.global_bus_to_local(glob_bus_or, cls.line_or_to_subid) - res[cls.line_or_pos_topo_vect[~line_status]] = -1 + self._topo_vect[cls.line_or_pos_topo_vect] = cls.global_bus_to_local(glob_bus_or, cls.line_or_to_subid) + self._topo_vect[cls.line_or_pos_topo_vect[~line_status]] = -1 glob_bus_ex = np.concatenate((self._grid.line["to_bus"].values, self._grid.trafo["lv_bus"].values)) - res[cls.line_ex_pos_topo_vect] = cls.global_bus_to_local(glob_bus_ex, cls.line_ex_to_subid) - res[cls.line_ex_pos_topo_vect[~line_status]] = -1 + self._topo_vect[cls.line_ex_pos_topo_vect] = cls.global_bus_to_local(glob_bus_ex, cls.line_ex_to_subid) + self._topo_vect[cls.line_ex_pos_topo_vect[~line_status]] = -1 # load, gen load_status = self._grid.load["in_service"].values - res[cls.load_pos_topo_vect] = cls.global_bus_to_local(self._grid.load["bus"].values, cls.load_to_subid) - res[cls.load_pos_topo_vect[~load_status]] = -1 + self._topo_vect[cls.load_pos_topo_vect] = cls.global_bus_to_local(self._grid.load["bus"].values, cls.load_to_subid) + self._topo_vect[cls.load_pos_topo_vect[~load_status]] = -1 gen_status = self._grid.gen["in_service"].values - res[cls.gen_pos_topo_vect] = cls.global_bus_to_local(self._grid.gen["bus"].values, cls.gen_to_subid) - res[cls.gen_pos_topo_vect[~gen_status]] = -1 + self._topo_vect[cls.gen_pos_topo_vect] = cls.global_bus_to_local(self._grid.gen["bus"].values, cls.gen_to_subid) + self._topo_vect[cls.gen_pos_topo_vect[~gen_status]] = -1 # storage if cls.n_storage: storage_status = self._grid.storage["in_service"].values - res[cls.storage_pos_topo_vect] = cls.global_bus_to_local(self._grid.storage["bus"].values, cls.storage_to_subid) - res[cls.storage_pos_topo_vect[~storage_status]] = -1 - return res + self._topo_vect[cls.storage_pos_topo_vect] = cls.global_bus_to_local(self._grid.storage["bus"].values, cls.storage_to_subid) + self._topo_vect[cls.storage_pos_topo_vect[~storage_status]] = -1 + self._topo_vect.flags.writeable = False + return self._topo_vect def _gens_info(self): prod_p = self.cst_1 * self._grid.res_gen["p_mw"].values.astype(dt_float) @@ -1539,8 +1546,10 @@ def _storages_info(self): if self.n_storage: # this is because we support "backward comaptibility" feature. So the storage can be # deactivated from the Environment... - p_storage = self._grid.res_storage["p_mw"].values.astype(dt_float) - q_storage = self._grid.res_storage["q_mvar"].values.astype(dt_float) + # p_storage = self._grid.res_storage["p_mw"].values.astype(dt_float) + # q_storage = self._grid.res_storage["q_mvar"].values.astype(dt_float) + p_storage = self._grid.storage["p_mw"].values.astype(dt_float) + q_storage = self._grid.storage["q_mvar"].values.astype(dt_float) v_storage = ( self._grid.res_bus.loc[self._grid.storage["bus"].values][ "vm_pu" @@ -1564,4 +1573,4 @@ def _storages_info(self): def sub_from_bus_id(self, bus_id : int) -> int: if bus_id >= self._number_true_line: return bus_id - self._number_true_line - return bus_id + return bus_id \ No newline at end of file diff --git a/grid2op/Converter/BackendConverter.py b/grid2op/Converter/BackendConverter.py index 4c023c85..19e62c2a 100644 --- a/grid2op/Converter/BackendConverter.py +++ b/grid2op/Converter/BackendConverter.py @@ -165,10 +165,13 @@ def load_grid(self, path=None, filename=None): # register the "n_busbar_per_sub" (set for the backend class) # TODO in case source supports the "more than 2" feature but not target # it's unclear how I can "reload" the grid... - from grid2op.Space import DEFAULT_N_BUSBAR_PER_SUB + from grid2op.Space import DEFAULT_N_BUSBAR_PER_SUB, DEFAULT_ALLOW_DETACHMENT type(self.source_backend).set_n_busbar_per_sub(DEFAULT_N_BUSBAR_PER_SUB) type(self.target_backend).set_n_busbar_per_sub(DEFAULT_N_BUSBAR_PER_SUB) + type(self.source_backend).set_detachment_is_allowed(DEFAULT_ALLOW_DETACHMENT) + type(self.target_backend).set_detachment_is_allowed(DEFAULT_ALLOW_DETACHMENT) self.cannot_handle_more_than_2_busbar() + self.cannot_handle_detachment() self.source_backend.load_grid(path, filename) # and now i load the target backend diff --git a/grid2op/Environment/_forecast_env.py b/grid2op/Environment/_forecast_env.py index ab4d7056..0f468e0b 100644 --- a/grid2op/Environment/_forecast_env.py +++ b/grid2op/Environment/_forecast_env.py @@ -19,10 +19,10 @@ class _ForecastEnv(Environment): It is used by obs.get_forecast_env. """ - def __init__(self, *args, **kwargs): + def __init__(self,**kwargs): if "_update_obs_after_reward" not in kwargs: kwargs["_update_obs_after_reward"] = False - super().__init__(*args, **kwargs) + super().__init__(**kwargs) self._do_not_erase_local_dir_cls = True def step(self, action: BaseAction) -> Tuple[BaseObservation, float, bool, STEP_INFO_TYPING]: diff --git a/grid2op/Environment/_obsEnv.py b/grid2op/Environment/_obsEnv.py index 4048cedb..c8b8ac79 100644 --- a/grid2op/Environment/_obsEnv.py +++ b/grid2op/Environment/_obsEnv.py @@ -18,6 +18,7 @@ from grid2op.Chronics import ChangeNothing from grid2op.Chronics._obs_fake_chronics_handler import _ObsCH from grid2op.Rules import RulesChecker +from grid2op.Space import DEFAULT_ALLOW_DETACHMENT from grid2op.operator_attention import LinearAttentionBudget from grid2op.Environment.baseEnv import BaseEnv @@ -41,6 +42,7 @@ class _ObsEnv(BaseEnv): def __init__( self, + *, # since 1.11.0 I force kwargs init_env_path, init_grid_path, backend_instanciated, @@ -63,16 +65,17 @@ def __init__( logger=None, highres_sim_counter=None, _complete_action_cls=None, + allow_detachment:bool=DEFAULT_ALLOW_DETACHMENT, _ptr_orig_obs_space=None, _local_dir_cls=None, # only set at the first call to `make(...)` after should be false _read_from_local_dir=None, ): BaseEnv.__init__( self, - init_env_path, - init_grid_path, - copy.deepcopy(parameters), - thermal_limit_a, + init_env_path=init_env_path, + init_grid_path=init_grid_path, + parameters=copy.deepcopy(parameters), + thermal_limit_a=thermal_limit_a, other_rewards=other_rewards, epsilon_poly=epsilon_poly, tol_poly=tol_poly, @@ -84,7 +87,8 @@ def __init__( highres_sim_counter=highres_sim_counter, update_obs_after_reward=False, _local_dir_cls=_local_dir_cls, - _read_from_local_dir=_read_from_local_dir + _read_from_local_dir=_read_from_local_dir, + allow_detachment=allow_detachment ) self._do_not_erase_local_dir_cls = True self.__unusable = False # unsuable if backend cannot be copied diff --git a/grid2op/Environment/baseEnv.py b/grid2op/Environment/baseEnv.py index 2bf1f283..e3c32323 100644 --- a/grid2op/Environment/baseEnv.py +++ b/grid2op/Environment/baseEnv.py @@ -28,14 +28,23 @@ HighResSimCounter) from grid2op.Backend import Backend from grid2op.dtypes import dt_int, dt_float, dt_bool -from grid2op.Space import GridObjects, RandomObject, GRID2OP_CLASSES_ENV_FOLDER +from grid2op.Space import (GridObjects, + RandomObject, + DEFAULT_ALLOW_DETACHMENT, + DEFAULT_N_BUSBAR_PER_SUB, + GRID2OP_CLASSES_ENV_FOLDER) +from grid2op.typing_variables import N_BUSBAR_PER_SUB_TYPING from grid2op.Exceptions import (Grid2OpException, EnvError, InvalidRedispatching, GeneratorTurnedOffTooSoon, GeneratorTurnedOnTooSoon, AmbiguousActionRaiseAlert, - ImpossibleTopology) + ImpossibleTopology, + SomeGeneratorAbovePmax, + SomeGeneratorBelowPmin, + SomeGeneratorAboveRampmax, + SomeGeneratorBelowRampmin) from grid2op.Parameters import Parameters from grid2op.Reward import BaseReward, RewardHelper from grid2op.Opponent import OpponentSpace, NeverAttackBudget, BaseOpponent @@ -45,6 +54,7 @@ from grid2op.Chronics import ChronicsHandler from grid2op.Rules import AlwaysLegal, BaseRules, AlwaysLegal from grid2op.typing_variables import STEP_INFO_TYPING, RESET_OPTIONS_TYPING +from grid2op.VoltageControler import ControlVoltageFromFile # TODO put in a separate class the redispatching function @@ -311,10 +321,11 @@ def foo(manager): def __init__( self, + *, # since 1.11.0 I force kwargs init_env_path: os.PathLike, init_grid_path: os.PathLike, parameters: Parameters, - voltagecontrolerClass: type, + voltagecontrolerClass: type=ControlVoltageFromFile, name="unknown", thermal_limit_a: Optional[np.ndarray] = None, epsilon_poly: float = 1e-4, # precision of the redispatching algorithm @@ -339,7 +350,8 @@ def __init__( observation_bk_kwargs=None, # type of backend for the observation space highres_sim_counter=None, update_obs_after_reward=False, - n_busbar=2, + n_busbar:N_BUSBAR_PER_SUB_TYPING=DEFAULT_N_BUSBAR_PER_SUB, + allow_detachment:bool=DEFAULT_ALLOW_DETACHMENT, _is_test: bool = False, # TODO not implemented !! _init_obs: Optional[BaseObservation] =None, _local_dir_cls=None, @@ -366,6 +378,8 @@ def __init__( self._raw_backend_class = _raw_backend_class self._n_busbar = n_busbar # env attribute not class attribute ! + self._allow_detachment = allow_detachment + if other_rewards is None: other_rewards = {} if kwargs_attention_budget is None: @@ -637,6 +651,21 @@ def __init__( # general things that can be used by the reward self._reward_to_obs = {} + + # detachement (1.11.0) + self._loads_detached = None + self._gens_detached = None + self._storages_detached = None + self._prev_load_p = None + self._load_p_detached = None + self._prev_load_q = None + self._load_q_detached = None + self._prev_gen_p = None + self._gen_p_detached = None + self._storage_p_detached = None + + # slack (1.11.0) + self._delta_gen_p = None @property def highres_sim_counter(self): @@ -662,6 +691,7 @@ def _custom_deepcopy_for_copy(self, new_obj, dict_=None): if dict_ is None: dict_ = {} new_obj._n_busbar = self._n_busbar + new_obj._allow_detachment = self._allow_detachment new_obj._init_grid_path = copy.deepcopy(self._init_grid_path) new_obj._init_env_path = copy.deepcopy(self._init_env_path) @@ -941,6 +971,21 @@ def _custom_deepcopy_for_copy(self, new_obj, dict_=None): # breaks for some version of lightsim2grid... (a powerflow need to be run to retrieve the observation) new_obj.current_obs = new_obj.get_obs() + # detachment (1.11.0) + new_obj._loads_detached = copy.deepcopy(self._loads_detached) + new_obj._gens_detached = copy.deepcopy(self._gens_detached) + new_obj._storages_detached = copy.deepcopy(self._storages_detached) + new_obj._prev_load_p = 1. * self._prev_load_p + new_obj._load_p_detached = 1. * self._load_p_detached + new_obj._prev_load_q = 1. * self._prev_load_q + new_obj._load_q_detached = 1. * self._load_q_detached + new_obj._prev_gen_p = 1. * self._prev_gen_p + new_obj._gen_p_detached = 1. * self._gen_p_detached + new_obj._storage_p_detached = 1. * self._storage_p_detached + + # slack (1.11.0) + new_obj._delta_gen_p = 1. * self._delta_gen_p + def get_path_env(self): """ Get the path that allows to create this environment. @@ -1330,41 +1375,41 @@ def _has_been_initialized(self): self._backend_action = self._backend_action_class() # initialize maintenance / hazards - self._time_next_maintenance = np.full(self.n_line, -1, dtype=dt_int) - self._duration_next_maintenance = np.zeros(shape=(self.n_line,), dtype=dt_int) + self._time_next_maintenance = np.full(bk_type.n_line, -1, dtype=dt_int) + self._duration_next_maintenance = np.zeros(shape=(bk_type.n_line,), dtype=dt_int) self._times_before_line_status_actionable = np.full( - shape=(self.n_line,), fill_value=0, dtype=dt_int + shape=(bk_type.n_line,), fill_value=0, dtype=dt_int ) # create the vector to the proper shape - self._target_dispatch = np.zeros(self.n_gen, dtype=dt_float) - self._already_modified_gen = np.zeros(self.n_gen, dtype=dt_bool) - self._actual_dispatch = np.zeros(self.n_gen, dtype=dt_float) - self._gen_uptime = np.zeros(self.n_gen, dtype=dt_int) - self._gen_downtime = np.zeros(self.n_gen, dtype=dt_int) - self._gen_activeprod_t = np.zeros(self.n_gen, dtype=dt_float) - self._gen_activeprod_t_redisp = np.zeros(self.n_gen, dtype=dt_float) + self._target_dispatch = np.zeros(bk_type.n_gen, dtype=dt_float) + self._already_modified_gen = np.zeros(bk_type.n_gen, dtype=dt_bool) + self._actual_dispatch = np.zeros(bk_type.n_gen, dtype=dt_float) + self._gen_uptime = np.zeros(bk_type.n_gen, dtype=dt_int) + self._gen_downtime = np.zeros(bk_type.n_gen, dtype=dt_int) + self._gen_activeprod_t = np.zeros(bk_type.n_gen, dtype=dt_float) + self._gen_activeprod_t_redisp = np.zeros(bk_type.n_gen, dtype=dt_float) self._max_timestep_line_status_deactivated = ( self._parameters.NB_TIMESTEP_COOLDOWN_LINE ) self._times_before_line_status_actionable = np.zeros( - shape=(self.n_line,), dtype=dt_int + shape=(bk_type.n_line,), dtype=dt_int ) self._times_before_topology_actionable = np.zeros( - shape=(self.n_sub,), dtype=dt_int + shape=(bk_type.n_sub,), dtype=dt_int ) self._nb_timestep_overflow_allowed = np.full( - shape=(self.n_line,), + shape=(bk_type.n_line,), fill_value=self._parameters.NB_TIMESTEP_OVERFLOW_ALLOWED, dtype=dt_int, ) self._hard_overflow_threshold = np.full( - shape=(self.n_line,), + shape=(bk_type.n_line,), fill_value=self._parameters.HARD_OVERFLOW_THRESHOLD, dtype=dt_float, ) - self._timestep_overflow = np.zeros(shape=(self.n_line,), dtype=dt_int) + self._timestep_overflow = np.zeros(shape=(bk_type.n_line,), dtype=dt_int) # update the parameters self.__new_param = self._parameters # small hack to have it working as expected @@ -1373,28 +1418,43 @@ def _has_been_initialized(self): self._reset_redispatching() # storage - self._storage_current_charge = np.zeros(self.n_storage, dtype=dt_float) - self._storage_previous_charge = np.zeros(self.n_storage, dtype=dt_float) - self._action_storage = np.zeros(self.n_storage, dtype=dt_float) - self._storage_power = np.zeros(self.n_storage, dtype=dt_float) - self._storage_power_prev = np.zeros(self.n_storage, dtype=dt_float) + self._storage_current_charge = np.zeros(bk_type.n_storage, dtype=dt_float) + self._storage_previous_charge = np.zeros(bk_type.n_storage, dtype=dt_float) + self._action_storage = np.zeros(bk_type.n_storage, dtype=dt_float) + self._storage_power = np.zeros(bk_type.n_storage, dtype=dt_float) + self._storage_power_prev = np.zeros(bk_type.n_storage, dtype=dt_float) self._amount_storage = 0.0 self._amount_storage_prev = 0.0 # curtailment self._limit_curtailment = np.ones( - self.n_gen, dtype=dt_float + bk_type.n_gen, dtype=dt_float ) # in ratio of pmax self._limit_curtailment_prev = np.ones( - self.n_gen, dtype=dt_float + bk_type.n_gen, dtype=dt_float ) # in ratio of pmax - self._gen_before_curtailment = np.zeros(self.n_gen, dtype=dt_float) # in MW + self._gen_before_curtailment = np.zeros(bk_type.n_gen, dtype=dt_float) # in MW self._sum_curtailment_mw = dt_float(0.0) self._sum_curtailment_mw_prev = dt_float(0.0) self._reset_curtailment() # register this is properly initialized self.__is_init = True + + # detachment (1.11.0) + self._loads_detached = np.zeros(bk_type.n_load, dtype=dt_bool) + self._gens_detached = np.zeros(bk_type.n_gen, dtype=dt_bool) + self._storages_detached = np.zeros(bk_type.n_storage, dtype=dt_bool) + self._prev_load_p = np.zeros(bk_type.n_load, dtype=dt_float) + self._load_p_detached = np.zeros(bk_type.n_load, dtype=dt_float) + self._prev_load_q = np.zeros(bk_type.n_load, dtype=dt_float) + self._load_q_detached = np.zeros(bk_type.n_load, dtype=dt_float) + self._prev_gen_p = np.zeros(bk_type.n_gen, dtype=dt_float) + self._gen_p_detached = np.zeros(bk_type.n_gen, dtype=dt_float) + self._storage_p_detached = np.zeros(bk_type.n_storage, dtype=dt_float) + + # slack (1.11.0) + self._delta_gen_p = np.zeros(bk_type.n_gen, dtype=dt_float) def _update_parameters(self): """update value for the new parameters""" @@ -1481,9 +1541,24 @@ def reset(self, self._reset_storage() self._reset_curtailment() self._reset_alert() + self._reset_slack_and_detachment() self._reward_to_obs = {} self._has_just_been_seeded = False + def _reset_slack_and_detachment(self): + self._loads_detached[:] = False + self._gens_detached[:] = False + self._storages_detached[:] = False + self._prev_load_p[:] = 0. + self._load_p_detached[:] = 0. + self._prev_load_q[:] = 0. + self._load_q_detached[:] = 0. + self._prev_gen_p[:] = 0. + self._gen_p_detached[:] = 0. + self._storage_p_detached[:] = 0. + + self._delta_gen_p[:] = 0. + def _reset_alert(self): self._last_alert[:] = False self._is_already_attacked[:] = False @@ -1599,7 +1674,7 @@ def seed(self, seed=None, _seed_me=True): raise Grid2OpException( "Impossible to seed with the seed provided. Make sure it can be converted to a" "numpy 32 bits integer." - ) + ) from exc_ # example from gym # self.np_random, seed = seeding.np_random(seed) # inspiration from @ https://github.com/openai/gym/tree/master/gym/utils @@ -1877,6 +1952,23 @@ def _reset_redispatching(self): self._gen_activeprod_t[:] = 0.0 self._gen_activeprod_t_redisp[:] = 0.0 + def _feed_data_for_detachment(self, new_p_th): + """feed the attribute for the detachment""" + + self._prev_gen_p[:] = new_p_th + self._aux_retrieve_modif_act(self._prev_load_p, self._env_modification, "load_p") + self._aux_retrieve_modif_act(self._prev_load_q, self._env_modification, "load_q") + + def _aux_retrieve_modif_act(self, + input_ : np.ndarray, + act: BaseAction, + key: Literal["prod_p", "prod_v", "load_p", "load_q"]): + """It does modify directly its imput !""" + if key in act._dict_inj: + tmp = act._dict_inj[key] + indx_ok = np.isfinite(tmp) + input_[indx_ok] = tmp[indx_ok] + def _get_new_prod_setpoint(self, action): """ NB this is overidden in _ObsEnv where the data are read from the action to set this environment @@ -1884,18 +1976,11 @@ def _get_new_prod_setpoint(self, action): """ # get the modification of generator active setpoint from the action new_p = 1.0 * self._gen_activeprod_t - if "prod_p" in action._dict_inj: - tmp = action._dict_inj["prod_p"] - indx_ok = np.isfinite(tmp) - new_p[indx_ok] = tmp[indx_ok] + self._aux_retrieve_modif_act(new_p, action, "prod_p") # modification of the environment always override the modification of the agents (if any) # TODO have a flag there if this is the case. - if "prod_p" in self._env_modification._dict_inj: - # modification of the production setpoint value - tmp = self._env_modification._dict_inj["prod_p"] - indx_ok = np.isfinite(tmp) - new_p[indx_ok] = tmp[indx_ok] + self._aux_retrieve_modif_act(new_p, self._env_modification, "prod_p") return new_p def _get_already_modified_gen(self, action): @@ -2943,7 +3028,7 @@ def _aux_handle_attack(self, action: BaseAction): self._backend_action += attack return lines_attacked, subs_attacked, attack_duration - def _aux_apply_redisp(self, action, new_p, new_p_th, gen_curtailed, except_): + def _aux_apply_redisp(self, action, new_p, new_p_th, gen_curtailed, except_, powerline_status): is_illegal_redisp = False is_done = False is_illegal_reco = False @@ -2959,7 +3044,9 @@ def _aux_apply_redisp(self, action, new_p, new_p_th, gen_curtailed, except_): if except_tmp is not None: orig_action = action + action.reset_cache_topological_impact() action = self._action_space({}) + _ = action.get_topological_impact(powerline_status, _store_in_cache=True, _read_from_cache=False) if type(self).dim_alerts: action.raise_alert = orig_action.raise_alert is_illegal_redisp = True @@ -2991,7 +3078,9 @@ def _aux_apply_redisp(self, action, new_p, new_p_th, gen_curtailed, except_): if not valid_disp or except_tmp is not None: # game over case (divergence of the scipy routine to compute redispatching) + action.reset_cache_topological_impact() res_action = self._action_space({}) + _ = res_action.get_topological_impact(powerline_status, _store_in_cache=True, _read_from_cache=False) if type(self).dim_alerts: res_action.raise_alert = action.raise_alert is_illegal_redisp = True @@ -3013,7 +3102,9 @@ def _aux_apply_redisp(self, action, new_p, new_p_th, gen_curtailed, except_): except_tmp = self._handle_updown_times(gen_up_before, self._actual_dispatch) if except_tmp is not None: is_illegal_reco = True + action.reset_cache_topological_impact() res_action = self._action_space({}) + _ = res_action.get_topological_impact(powerline_status, _store_in_cache=True, _read_from_cache=False) if type(self).dim_alerts: res_action.raise_alert = action.raise_alert except_.append(except_tmp) @@ -3078,11 +3169,10 @@ def _update_alert_properties(self, action, lines_attacked, subs_attacked): # TODO after alert budget will be implemented ! # self._is_alert_illegal - def _aux_register_env_converged(self, disc_lines, action, init_line_status, new_p): + def _aux_register_env_converged(self, disc_lines, action, init_line_status, new_p) -> Optional[Grid2OpException]: beg_res = time.perf_counter() - self.backend.update_thermal_limit( - self - ) # update the thermal limit, for DLR for example + # update the thermal limit, for DLR for example + self.backend.update_thermal_limit(self) overflow_lines = self.backend.get_line_overflow() # save the current topology as "last" topology (for connected powerlines) # and update the state of the disconnected powerline due to cascading failure @@ -3105,7 +3195,7 @@ def _aux_register_env_converged(self, disc_lines, action, init_line_status, new_ self._timestep_overflow[~overflow_lines] = 0 # build the topological action "cooldown" - aff_lines, aff_subs = action.get_topological_impact(init_line_status) + aff_lines, aff_subs = action.get_topological_impact(_read_from_cache=True) if self._max_timestep_line_status_deactivated > 0: # i update the cooldown only when this does not impact the line disconnected for the # opponent or by maintenance for example @@ -3128,21 +3218,55 @@ def _aux_register_env_converged(self, disc_lines, action, init_line_status, new_ ] = self._max_timestep_topology_deactivated # extract production active value at this time step (should be independent of action class) - self._gen_activeprod_t[:], *_ = self.backend.generators_info() + tmp_gen_p, *_ = self.backend.generators_info() + if not self._parameters.STOP_EP_IF_SLACK_BREAK_CONSTRAINTS: + # default behaviour, no check performed + self._gen_activeprod_t[:] = tmp_gen_p + else: + # I need to check whether all generators meet the constraints + cls = type(self) + if tmp_gen_p[cls.gen_redispatchable] > cls.gen_pmax[cls.gen_redispatchable] + self._tol_poly: + gen_ko = (tmp_gen_p[cls.gen_redispatchable] > cls.gen_pmax[cls.gen_redispatchable]).nonzero()[0] + gen_ko_nms = cls.name_gen[cls.gen_redispatchable][gen_ko] + return SomeGeneratorAbovePmax(f"Especially generators id {gen_ko_nms}") + if tmp_gen_p[cls.gen_redispatchable] < cls.gen_pmin[cls.gen_redispatchable] - self._tol_pol: + gen_ko = (tmp_gen_p[cls.gen_redispatchable] < cls.gen_pmin[cls.gen_redispatchable]).nonzero()[0] + gen_ko_nms = cls.name_gen[cls.gen_redispatchable][gen_ko] + return SomeGeneratorBelowPmin(f"Especially generators {gen_ko_nms}") + diff_ = tmp_gen_p - self._gen_activeprod_t + + if diff_[cls.gen_redispatchable] > cls.gen_max_ramp_up[cls.gen_redispatchable] + self._tol_poly: + gen_ko = (diff_[cls.gen_redispatchable] > cls.gen_max_ramp_up[cls.gen_redispatchable]).nonzero()[0] + gen_ko_nms = cls.name_gen[cls.gen_redispatchable][gen_ko] + return SomeGeneratorAboveRampmax(f"Especially generators {gen_ko_nms}") + if diff_[cls.gen_redispatchable] < -cls.gen_max_ramp_down[cls.gen_redispatchable] - self._tol_poly: + gen_ko = (diff_[cls.gen_redispatchable] < -cls.gen_max_ramp_down[cls.gen_redispatchable]).nonzero()[0] + gen_ko_nms = cls.name_gen[cls.gen_redispatchable][gen_ko] + return SomeGeneratorBelowRampmin(f"Especially generators {gen_ko_nms}") + + self._gen_activeprod_t[:] = tmp_gen_p + # problem with the gen_activeprod_t above, is that the slack bus absorbs alone all the losses # of the system. So basically, when it's too high (higher than the ramp) it can # mess up the rest of the environment self._gen_activeprod_t_redisp[:] = new_p + self._actual_dispatch # set the line status - self._line_status[:] = copy.deepcopy(self.backend.get_line_status()) + self._line_status[:] = self.backend.get_line_status() + # for detachment remember previous loads and generation + self._prev_load_p[:], self._prev_load_q[:], *_ = self.backend.loads_info() + self._delta_gen_p[:] = self._gen_activeprod_t - self._gen_activeprod_t_redisp + self._delta_gen_p[self._gens_detached] = 0. + self._prev_gen_p[:] = self._gen_activeprod_t + # finally, build the observation (it's a different one at each step, we cannot reuse the same one) # THIS SHOULD BE DONE AFTER EVERYTHING IS INITIALIZED ! self.current_obs = self.get_obs(_do_copy=False) # TODO storage: get back the result of the storage ! with the illegal action when a storage unit # TODO is non zero and disconnected, this should be ok. self._time_extract_obs += time.perf_counter() - beg_res + return None def _backend_next_grid_state(self): """overlaoded in MaskedEnv""" @@ -3153,6 +3277,26 @@ def _aux_run_pf_after_state_properly_set( ): has_error = True detailed_info = None + cls = type(self) + if cls.detachment_is_allowed: + self._loads_detached[:] = self._backend_action.get_load_detached() + self._gens_detached[:] = self._backend_action.get_gen_detached() + self._storages_detached[:] = self._backend_action.get_sto_detached() + + self._load_p_detached[:] = self._prev_load_p + self._load_p_detached[~self._loads_detached] = 0. + + self._load_q_detached[:] = self._prev_load_q + self._load_q_detached[~self._loads_detached] = 0. + + self._gen_p_detached[:] = self._prev_gen_p + self._gen_p_detached[~self._gens_detached] = 0. + + self._storage_p_detached[:] = 0. + mask_chgt = self._backend_action.storage_power.changed + self._storage_p_detached[mask_chgt] = self._backend_action.storage_power.values[mask_chgt] + self._storage_p_detached[~self._storages_detached] = 0. + try: # compute the next _grid state beg_pf = time.perf_counter() @@ -3161,10 +3305,14 @@ def _aux_run_pf_after_state_properly_set( self._time_powerflow += time.perf_counter() - beg_pf if conv_ is None: # everything went well, so i register what is needed - self._aux_register_env_converged( + maybe_error = self._aux_register_env_converged( disc_lines, action, init_line_status, new_p ) - has_error = False + if maybe_error is None: + has_error = False + else: + has_error = True + except_.append(maybe_error) else: except_.append(conv_) except Grid2OpException as exc_: @@ -3332,11 +3480,22 @@ def step(self, action: BaseAction) -> Tuple[BaseObservation, else: action.raise_alert = init_alert except_.append(except_tmp) - + + # speed optimization: during all the "env.step" the "topological impact" + # of an action is called multiple times, I cache the results + # at first iteration, self.current_obs is None so I cannot use self.current_obs.line_status + powerline_status = self.get_current_line_status() + # explicitly store in cache the topological impact (not to recompute it again and again) + # and this regardless of the + _ = action.get_topological_impact(powerline_status, _store_in_cache=True, _read_from_cache=False) + is_legal, reason = self._game_rules(action=action, env=self) if not is_legal: # action is replace by do nothing + action.reset_cache_topological_impact() action = self._action_space({}) + _ = action.get_topological_impact(powerline_status, _store_in_cache=True, _read_from_cache=False) + init_disp = 1.0 * action._redispatch # dispatching action action_storage_power = ( 1.0 * action._storage_power @@ -3362,7 +3521,8 @@ def step(self, action: BaseAction) -> Tuple[BaseObservation, ) new_p = self._get_new_prod_setpoint(action) new_p_th = 1.0 * new_p - + self._feed_data_for_detachment(new_p_th) + # storage unit if cls.n_storage > 0: # limiting the storage units is done in `_aux_apply_redisp` @@ -3371,16 +3531,16 @@ def step(self, action: BaseAction) -> Tuple[BaseObservation, # curtailment (does not attempt to "limit" the curtailment to make sure # it is feasible) - self._gen_before_curtailment[self.gen_renewable] = new_p[self.gen_renewable] + self._gen_before_curtailment[cls.gen_renewable] = new_p[cls.gen_renewable] gen_curtailed = self._aux_handle_curtailment_without_limit(action, new_p) beg__redisp = time.perf_counter() - if cls.redispatching_unit_commitment_availble or cls.n_storage > 0.0: + if (cls.redispatching_unit_commitment_availble or cls.n_storage > 0.0) and self._parameters.ENV_DOES_REDISPATCHING: # this computes the "optimal" redispatching # and it is also in this function that the limiting of the curtailment / storage actions # is perform to make the state "feasible" res_disp = self._aux_apply_redisp( - action, new_p, new_p_th, gen_curtailed, except_ + action, new_p, new_p_th, gen_curtailed, except_, powerline_status ) action, is_illegal_redisp, is_illegal_reco, is_done = res_disp @@ -3478,7 +3638,8 @@ def step(self, action: BaseAction) -> Tuple[BaseObservation, if self._update_obs_after_reward and self.current_obs is not None: # transfer some information computed in the reward into the obs (if any) self.current_obs.update_after_reward(self) - + + action.reset_cache_topological_impact() # TODO documentation on all the possible way to be illegal now if self.done: self.__is_init = False diff --git a/grid2op/Environment/environment.py b/grid2op/Environment/environment.py index 90a8b704..6f13d926 100644 --- a/grid2op/Environment/environment.py +++ b/grid2op/Environment/environment.py @@ -32,7 +32,7 @@ from grid2op.Environment.baseEnv import BaseEnv from grid2op.Opponent import BaseOpponent, NeverAttackBudget from grid2op.operator_attention import LinearAttentionBudget -from grid2op.Space import DEFAULT_N_BUSBAR_PER_SUB +from grid2op.Space import DEFAULT_N_BUSBAR_PER_SUB, DEFAULT_ALLOW_DETACHMENT from grid2op.typing_variables import RESET_OPTIONS_TYPING, N_BUSBAR_PER_SUB_TYPING from grid2op.MakeEnv.PathUtils import USE_CLASS_IN_FILE @@ -79,13 +79,15 @@ class Environment(BaseEnv): def __init__( self, + *, # since 1.11.0 I force kwargs init_env_path: str, init_grid_path: str, chronics_handler, backend, parameters, name="unknown", - n_busbar : N_BUSBAR_PER_SUB_TYPING=DEFAULT_N_BUSBAR_PER_SUB, + n_busbar:N_BUSBAR_PER_SUB_TYPING=DEFAULT_N_BUSBAR_PER_SUB, + allow_detachment:bool=DEFAULT_ALLOW_DETACHMENT, names_chronics_to_backend=None, actionClass=TopologyAction, observationClass=CompleteObservation, @@ -156,6 +158,7 @@ def __init__( highres_sim_counter=highres_sim_counter, update_obs_after_reward=_update_obs_after_reward, n_busbar=n_busbar, # TODO n_busbar_per_sub different num per substations: read from a config file maybe (if not provided by the user) + allow_detachment=allow_detachment, name=name, _raw_backend_class=_raw_backend_class if _raw_backend_class is not None else type(backend), _init_obs=_init_obs, @@ -262,11 +265,13 @@ def _init_backend( need_process_backend = False if not self.backend.is_loaded: if hasattr(self.backend, "init_pp_backend") and self.backend.init_pp_backend is not None: - # hack for lightsim2grid ... + # hack for legacy lightsim2grid ... if type(self.backend.init_pp_backend)._INIT_GRID_CLS is not None: type(self.backend.init_pp_backend)._INIT_GRID_CLS._clear_grid_dependant_class_attributes() + type(self.backend.init_pp_backend)._INIT_GRID_CLS.shunts_data_available = self.backend.shunts_data_available type(self.backend.init_pp_backend)._clear_grid_dependant_class_attributes() - + type(self.backend.init_pp_backend).shunts_data_available = self.backend.shunts_data_available + # usual case: the backend is not loaded # NB it is loaded when the backend comes from an observation for # example @@ -278,8 +283,11 @@ def _init_backend( # this is due to the class attribute type(self.backend).set_env_name(self.name) type(self.backend).set_n_busbar_per_sub(self._n_busbar) + type(self.backend).shunts_data_available = self.backend.shunts_data_available + type(self.backend).set_detachment_is_allowed(self._allow_detachment) if self._compat_glop_version is not None: type(self.backend).glop_version = self._compat_glop_version + self.backend.load_grid( self._init_grid_path ) # the real powergrid of the environment @@ -290,8 +298,8 @@ def _init_backend( except BackendError as exc_: self.backend.redispatching_unit_commitment_availble = False warnings.warn(f"Impossible to load redispatching data. This is not an error but you will not be able " - f"to use all grid2op functionalities. " - f"The error was: \"{exc_}\"") + f"to use all grid2op functionalities. " + f"The error was: \"{exc_}\"") exc_ = self.backend.load_grid_layout(self.get_path_env()) if exc_ is not None: warnings.warn( @@ -422,7 +430,7 @@ def _init_backend( kwargs_observation=self._kwargs_observation, observation_bk_class=self._observation_bk_class, observation_bk_kwargs=self._observation_bk_kwargs, - _local_dir_cls=self._local_dir_cls + _local_dir_cls=self._local_dir_cls, ) # test to make sure the backend is consistent with the chronics generator @@ -1461,7 +1469,14 @@ def render(self, mode="rgb_array"): self.viewer_fig = fig # Return the rgb array - rgb_array = np.frombuffer(fig.canvas.tostring_rgb(), dtype=np.uint8).reshape(self._viewer.height, self._viewer.width, 3) + try: + import matplotlib.colors + tmp = fig.canvas.tostring_argb() + argb_array = np.frombuffer(tmp, dtype=np.uint8).reshape(self._viewer.height, self._viewer.width, 4) + rgb_array = argb_array[:,:,1:] + except AttributeError: + tmp = fig.canvas.tostring_rgb() + rgb_array = np.frombuffer(tmp, dtype=np.uint8).reshape(self._viewer.height, self._viewer.width, 3) return rgb_array def _custom_deepcopy_for_copy(self, new_obj): @@ -1540,6 +1555,7 @@ def get_kwargs(self, """ res = {} res["n_busbar"] = self._n_busbar + res["allow_detachment"] = self._allow_detachment res["init_env_path"] = self._init_env_path res["init_grid_path"] = self._init_grid_path if with_chronics_handler: @@ -2192,6 +2208,7 @@ def get_params_for_runner(self): res["grid_layout"] = self.grid_layout res["name_env"] = self.name res["n_busbar"] = self._n_busbar + res["allow_detachment"] = self._allow_detachment res["opponent_space_type"] = self._opponent_space_type res["opponent_action_class"] = self._opponent_action_class @@ -2253,7 +2270,8 @@ def init_obj_from_kwargs(cls, _read_from_local_dir, _local_dir_cls, _overload_name_multimix, - n_busbar=DEFAULT_N_BUSBAR_PER_SUB + n_busbar=DEFAULT_N_BUSBAR_PER_SUB, + allow_detachment=DEFAULT_ALLOW_DETACHMENT, ): res = cls(init_env_path=init_env_path, init_grid_path=init_grid_path, @@ -2286,6 +2304,7 @@ def init_obj_from_kwargs(cls, observation_bk_class=observation_bk_class, observation_bk_kwargs=observation_bk_kwargs, n_busbar=int(n_busbar), + allow_detachment=bool(allow_detachment), _raw_backend_class=_raw_backend_class, _read_from_local_dir=_read_from_local_dir, _local_dir_cls=_local_dir_cls, diff --git a/grid2op/Environment/maskedEnvironment.py b/grid2op/Environment/maskedEnvironment.py index 12bf0611..81c69476 100644 --- a/grid2op/Environment/maskedEnvironment.py +++ b/grid2op/Environment/maskedEnvironment.py @@ -5,7 +5,7 @@ # you can obtain one at http://mozilla.org/MPL/2.0/. # SPDX-License-Identifier: MPL-2.0 # This file is part of Grid2Op, Grid2Op a testbed platform to model sequential decision making in power systems. - +import warnings import copy import warnings import numpy as np @@ -15,7 +15,7 @@ from grid2op.Environment.environment import Environment from grid2op.Exceptions import EnvError from grid2op.dtypes import dt_bool, dt_float, dt_int -from grid2op.Space import DEFAULT_N_BUSBAR_PER_SUB +from grid2op.Space import DEFAULT_N_BUSBAR_PER_SUB, DEFAULT_ALLOW_DETACHMENT from grid2op.MakeEnv.PathUtils import USE_CLASS_IN_FILE @@ -164,7 +164,8 @@ def init_obj_from_kwargs(cls, _read_from_local_dir, _overload_name_multimix, _local_dir_cls, - n_busbar=DEFAULT_N_BUSBAR_PER_SUB): + n_busbar=DEFAULT_N_BUSBAR_PER_SUB, + allow_detachment=DEFAULT_ALLOW_DETACHMENT): grid2op_env = {"init_env_path": init_env_path, "init_grid_path": init_grid_path, "chronics_handler": chronics_handler, @@ -196,6 +197,7 @@ def init_obj_from_kwargs(cls, "observation_bk_class": observation_bk_class, "observation_bk_kwargs": observation_bk_kwargs, "n_busbar": int(n_busbar), + "allow_detachment": bool(allow_detachment), "_raw_backend_class": _raw_backend_class, "_read_from_local_dir": _read_from_local_dir, "_local_dir_cls": _local_dir_cls, diff --git a/grid2op/Environment/multiMixEnv.py b/grid2op/Environment/multiMixEnv.py index af140350..b1664d64 100644 --- a/grid2op/Environment/multiMixEnv.py +++ b/grid2op/Environment/multiMixEnv.py @@ -14,7 +14,11 @@ from typing import Any, Dict, Tuple, Union, List, Literal, Optional from grid2op.dtypes import dt_int, dt_float -from grid2op.Space import GridObjects, RandomObject, DEFAULT_N_BUSBAR_PER_SUB, GRID2OP_CLASSES_ENV_FOLDER +from grid2op.Space import (GridObjects, + RandomObject, + DEFAULT_N_BUSBAR_PER_SUB, + GRID2OP_CLASSES_ENV_FOLDER, + DEFAULT_ALLOW_DETACHMENT) from grid2op.Exceptions import EnvError, Grid2OpException from grid2op.Backend import Backend from grid2op.Observation import BaseObservation @@ -213,6 +217,7 @@ def __init__( logger=None, experimental_read_from_local_dir=None, n_busbar=DEFAULT_N_BUSBAR_PER_SUB, + allow_detachment=DEFAULT_ALLOW_DETACHMENT, _add_cls_nm_bk=True, _add_to_name="", # internal, for test only, do not use ! _compat_glop_version=None, # internal, for test only, do not use ! @@ -268,6 +273,7 @@ def __init__( _add_to_name, _compat_glop_version, n_busbar, + allow_detachment, _test, experimental_read_from_local_dir, self.multi_env_name, @@ -297,6 +303,7 @@ def __init__( _add_to_name, _compat_glop_version, n_busbar, + allow_detachment, _test, experimental_read_from_local_dir, self.multi_env_name, @@ -385,6 +392,7 @@ def _aux_create_a_mix(self, _add_to_name, _compat_glop_version, n_busbar, + allow_detachment, _test, experimental_read_from_local_dir, multi_env_name : _OverloadNameMultiMixInfo, @@ -408,6 +416,7 @@ def _aux_create_a_mix(self, logger=this_logger, experimental_read_from_local_dir=experimental_read_from_local_dir, _overload_name_multimix=multi_env_name, + allow_detachment=allow_detachment, **kwargs) if is_first_mix: # in the first mix either I need to create the backend, or diff --git a/grid2op/Environment/timedOutEnv.py b/grid2op/Environment/timedOutEnv.py index a1952f99..377d55c6 100644 --- a/grid2op/Environment/timedOutEnv.py +++ b/grid2op/Environment/timedOutEnv.py @@ -5,7 +5,7 @@ # you can obtain one at http://mozilla.org/MPL/2.0/. # SPDX-License-Identifier: MPL-2.0 # This file is part of Grid2Op, Grid2Op a testbed platform to model sequential decision making in power systems. - +import warnings import time from math import floor from typing import Any, Dict, Tuple, Union, List, Literal @@ -15,7 +15,7 @@ from grid2op.Action import BaseAction from grid2op.Observation import BaseObservation from grid2op.Exceptions import EnvError -from grid2op.Space import DEFAULT_N_BUSBAR_PER_SUB +from grid2op.Space import DEFAULT_N_BUSBAR_PER_SUB, DEFAULT_ALLOW_DETACHMENT from grid2op.MakeEnv.PathUtils import USE_CLASS_IN_FILE @@ -232,7 +232,8 @@ def init_obj_from_kwargs(cls, _read_from_local_dir, _local_dir_cls, _overload_name_multimix, - n_busbar=DEFAULT_N_BUSBAR_PER_SUB): + n_busbar=DEFAULT_N_BUSBAR_PER_SUB, + allow_detachment=DEFAULT_ALLOW_DETACHMENT): grid2op_env={"init_env_path": init_env_path, "init_grid_path": init_grid_path, "chronics_handler": chronics_handler, @@ -266,6 +267,7 @@ def init_obj_from_kwargs(cls, "_raw_backend_class": _raw_backend_class, "_read_from_local_dir": _read_from_local_dir, "n_busbar": int(n_busbar), + "allow_detachment": bool(allow_detachment), "_local_dir_cls": _local_dir_cls, "_overload_name_multimix": _overload_name_multimix} if not "time_out_ms" in other_env_kwargs: diff --git a/grid2op/Exceptions/__init__.py b/grid2op/Exceptions/__init__.py index f75a3bba..0e68b547 100644 --- a/grid2op/Exceptions/__init__.py +++ b/grid2op/Exceptions/__init__.py @@ -59,7 +59,11 @@ "NotEnoughAttentionBudget", "AgentError", "SimulatorError", - "HandlerError" + "HandlerError", + "SomeGeneratorAbovePmax", + "SomeGeneratorBelowPmin", + "SomeGeneratorAboveRampmax", + "SomeGeneratorBelowRampmin" ] from grid2op.Exceptions.grid2OpException import Grid2OpException @@ -76,7 +80,11 @@ IncorrectPositionOfLines, IncorrectPositionOfStorages, UnknownEnv, - MultiEnvException) + MultiEnvException, + SomeGeneratorAbovePmax, + SomeGeneratorBelowPmin, + SomeGeneratorAboveRampmax, + SomeGeneratorBelowRampmin) from grid2op.Exceptions.illegalActionExceptions import (IllegalAction, OnProduction, diff --git a/grid2op/Exceptions/envExceptions.py b/grid2op/Exceptions/envExceptions.py index 33d3f9b3..eb620e9d 100644 --- a/grid2op/Exceptions/envExceptions.py +++ b/grid2op/Exceptions/envExceptions.py @@ -111,6 +111,52 @@ class IncorrectPositionOfStorages(EnvError): pass +class SomeGeneratorAbovePmax(EnvError): + """This is a more precise exception saying that, at the end of the simulation, some generator would + have their production above pmax, which is not possible in practice. + + .. versionadded:: 1.11.0 + + This can only be triggered if :attr:`grid2op.Parameters.Parameters.STOP_EP_IF_SLACK_BREAK_CONSTRAINTS` + is ``True`` (which is not the default). + """ + pass + + +class SomeGeneratorBelowPmin(EnvError): + """This is a more precise exception saying that, at the end of the simulation, some generator would + have their production below pmin, which is not possible in practice. + + .. versionadded:: 1.11.0 + + This can only be triggered if :attr:`grid2op.Parameters.Parameters.STOP_EP_IF_SLACK_BREAK_CONSTRAINTS` + is ``True`` (which is not the default). + """ + pass + +class SomeGeneratorAboveRampmax(EnvError): + """This is a more precise exception saying that, at the end of the simulation, some generator would + have their production vary too much, which is not possible in practice. + + .. versionadded:: 1.11.0 + + This can only be triggered if :attr:`grid2op.Parameters.Parameters.STOP_EP_IF_SLACK_BREAK_CONSTRAINTS` + is ``True`` (which is not the default). + """ + pass + + +class SomeGeneratorBelowRampmin(EnvError): + """This is a more precise exception saying that, at the end of the simulation, some generator would + have their production vary too much, which is not possible in practice. + + .. versionadded:: 1.11.0 + + This can only be triggered if :attr:`grid2op.Parameters.Parameters.STOP_EP_IF_SLACK_BREAK_CONSTRAINTS` + is ``True`` (which is not the default). + """ + pass + # Unknown environment at creation class UnknownEnv(Grid2OpException): """ diff --git a/grid2op/MakeEnv/Make.py b/grid2op/MakeEnv/Make.py index 89154b38..7ddf9c09 100644 --- a/grid2op/MakeEnv/Make.py +++ b/grid2op/MakeEnv/Make.py @@ -20,6 +20,7 @@ import grid2op.MakeEnv.PathUtils from grid2op.MakeEnv.PathUtils import _create_path_folder from grid2op.Download.DownloadDataset import _aux_download +from grid2op.Space import DEFAULT_ALLOW_DETACHMENT, DEFAULT_N_BUSBAR_PER_SUB _VAR_FORCE_TEST = "_GRID2OP_FORCE_TEST" @@ -247,7 +248,8 @@ def _aux_make_multimix( dataset_path, test=False, experimental_read_from_local_dir=False, - n_busbar=2, + n_busbar=DEFAULT_N_BUSBAR_PER_SUB, + allow_detachment=DEFAULT_ALLOW_DETACHMENT, _add_cls_nm_bk=True, _add_to_name="", _compat_glop_version=None, @@ -263,6 +265,7 @@ def _aux_make_multimix( dataset_path, experimental_read_from_local_dir=experimental_read_from_local_dir, n_busbar=n_busbar, + allow_detachment=allow_detachment, _test=test, _add_cls_nm_bk=_add_cls_nm_bk, _add_to_name=_add_to_name, @@ -287,7 +290,8 @@ def make( test : bool=False, logger: Optional[logging.Logger]=None, experimental_read_from_local_dir : bool=False, - n_busbar=2, + n_busbar=DEFAULT_N_BUSBAR_PER_SUB, + allow_detachment=DEFAULT_ALLOW_DETACHMENT, _add_cls_nm_bk=True, _add_to_name : str="", _compat_glop_version : Optional[str]=None, @@ -307,6 +311,9 @@ def make( .. versionadded:: 1.10.0 The `n_busbar` parameters + + .. versionadded:: 1.11.0 + The `allow_detachment` parameter Parameters ---------- @@ -331,6 +338,9 @@ def make( n_busbar: ``int`` Number of independant busbars allowed per substations. By default it's 2. + + allow_detachment: ``bool`` + Whether to allow loads and generators to be shed without a game over. By default it's False. kwargs: Other keyword argument to give more control on the environment you are creating. See @@ -394,6 +404,7 @@ def make( raise Grid2OpException(f"n_busbar parameters should be convertible to integer, but we have " f"int(n_busbar) = {n_busbar_int} != {n_busbar}") + accepted_kwargs = ERR_MSG_KWARGS.keys() | {"dataset", "test"} for el in kwargs: if el not in accepted_kwargs: @@ -448,6 +459,7 @@ def make_from_path_fn_(*args, **kwargs): _compat_glop_version=_compat_glop_version_tmp, _overload_name_multimix=_overload_name_multimix, n_busbar=n_busbar, + allow_detachment=allow_detachment, **kwargs ) @@ -494,6 +506,7 @@ def make_from_path_fn_(*args, **kwargs): dataset_path=ds_path, logger=logger, n_busbar=n_busbar, + allow_detachment=allow_detachment, _add_cls_nm_bk=_add_cls_nm_bk, _add_to_name=_add_to_name, _compat_glop_version=_compat_glop_version, @@ -510,6 +523,7 @@ def make_from_path_fn_(*args, **kwargs): real_ds_path, logger=logger, n_busbar=n_busbar, + allow_detachment=allow_detachment, _add_cls_nm_bk=_add_cls_nm_bk, experimental_read_from_local_dir=experimental_read_from_local_dir, _overload_name_multimix=_overload_name_multimix, @@ -531,6 +545,7 @@ def make_from_path_fn_(*args, **kwargs): dataset_path=real_ds_path, logger=logger, n_busbar=n_busbar, + allow_detachment=allow_detachment, experimental_read_from_local_dir=experimental_read_from_local_dir, _overload_name_multimix=_overload_name_multimix, _add_cls_nm_bk=_add_cls_nm_bk, diff --git a/grid2op/MakeEnv/MakeFromPath.py b/grid2op/MakeEnv/MakeFromPath.py index 39abd725..6692edfa 100644 --- a/grid2op/MakeEnv/MakeFromPath.py +++ b/grid2op/MakeEnv/MakeFromPath.py @@ -36,6 +36,7 @@ from grid2op.VoltageControler import ControlVoltageFromFile from grid2op.Opponent import BaseOpponent, BaseActionBudget, NeverAttackBudget from grid2op.operator_attention import LinearAttentionBudget +from grid2op.Space import DEFAULT_N_BUSBAR_PER_SUB, DEFAULT_ALLOW_DETACHMENT from grid2op.typing_variables import DICT_CONFIG_TYPING from grid2op.MakeEnv.get_default_aux import _get_default_aux @@ -130,7 +131,8 @@ def make_from_dataset_path( dataset_path="/", logger=None, experimental_read_from_local_dir=False, - n_busbar=2, + n_busbar=DEFAULT_N_BUSBAR_PER_SUB, + allow_detachment=DEFAULT_ALLOW_DETACHMENT, _add_cls_nm_bk=True, _add_to_name="", _compat_glop_version=None, @@ -170,6 +172,9 @@ def make_from_dataset_path( n_busbar: ``int`` Number of independant busbars allowed per substations. By default it's 2. + + allow_detachment; ``bool`` + Whether to allow loads/generators to be detached without a game over. By default False. action_class: ``type``, optional Type of BaseAction the BaseAgent will be able to perform. @@ -912,7 +917,44 @@ def make_from_dataset_path( # for the other mix I need to read the data from files and NOT # create the classes use_class_in_files = False + _add_to_name = '' # already defined in the first mix + name_env = _overload_name_multimix.name_env + + default_kwargs = dict( + init_env_path=os.path.abspath(dataset_path), + init_grid_path=grid_path_abs, + backend=backend, + parameters=param, + name=name_env + _add_to_name, + names_chronics_to_backend=names_chronics_to_backend, + actionClass=action_class, + observationClass=observation_class, + rewardClass=reward_class, + legalActClass=gamerules_class, + voltagecontrolerClass=volagecontroler_class, + other_rewards=other_rewards, + opponent_space_type=opponent_space_type, + opponent_action_class=opponent_action_class, + opponent_class=opponent_class, + opponent_init_budget=opponent_init_budget, + opponent_attack_duration=opponent_attack_duration, + opponent_attack_cooldown=opponent_attack_cooldown, + opponent_budget_per_ts=opponent_budget_per_ts, + opponent_budget_class=opponent_budget_class, + kwargs_opponent=kwargs_opponent, + has_attention_budget=has_attention_budget, + attention_budget_cls=attention_budget_class, + kwargs_attention_budget=kwargs_attention_budget, + logger=logger, + n_busbar=n_busbar, # TODO n_busbar_per_sub different num per substations: read from a config file maybe (if not provided by the user) + _compat_glop_version=_compat_glop_version, + _overload_name_multimix=_overload_name_multimix, + kwargs_observation=kwargs_observation, + observation_bk_class=observation_backend_class, + observation_bk_kwargs=observation_backend_kwargs, + allow_detachment=allow_detachment, + ) if use_class_in_files: # new behaviour if _overload_name_multimix is None: @@ -961,44 +1003,14 @@ def make_from_dataset_path( if not os.path.exists(this_local_dir_name): raise EnvError(f"Path {this_local_dir_name} has not been created by the tempfile package") - init_env = Environment(init_env_path=os.path.abspath(dataset_path), - init_grid_path=grid_path_abs, + init_env = Environment(**default_kwargs, chronics_handler=data_feeding_fake, - backend=backend, - parameters=param, - name=name_env + _add_to_name, - names_chronics_to_backend=names_chronics_to_backend, - actionClass=action_class, - observationClass=observation_class, - rewardClass=reward_class, - legalActClass=gamerules_class, - voltagecontrolerClass=volagecontroler_class, - other_rewards=other_rewards, - opponent_space_type=opponent_space_type, - opponent_action_class=opponent_action_class, - opponent_class=opponent_class, - opponent_init_budget=opponent_init_budget, - opponent_attack_duration=opponent_attack_duration, - opponent_attack_cooldown=opponent_attack_cooldown, - opponent_budget_per_ts=opponent_budget_per_ts, - opponent_budget_class=opponent_budget_class, - kwargs_opponent=kwargs_opponent, - has_attention_budget=has_attention_budget, - attention_budget_cls=attention_budget_class, - kwargs_attention_budget=kwargs_attention_budget, - logger=logger, - n_busbar=n_busbar, # TODO n_busbar_per_sub different num per substations: read from a config file maybe (if not provided by the user) - _compat_glop_version=_compat_glop_version, _read_from_local_dir=None, # first environment to generate the classes and save them _local_dir_cls=None, - _overload_name_multimix=_overload_name_multimix, - kwargs_observation=kwargs_observation, - observation_bk_class=observation_backend_class, - observation_bk_kwargs=observation_backend_kwargs ) - if not os.path.exists(this_local_dir_name): - raise EnvError(f"Path {this_local_dir_name} has not been created by the tempfile package") - init_env.generate_classes(local_dir_id=this_local_dir_name) + if not os.path.exists(this_local_dir.name): + raise EnvError(f"Path {this_local_dir.name} has not been created by the tempfile package") + init_env.generate_classes(local_dir_id=this_local_dir.name) # fix `my_bk_act_class` and `_complete_action_class` _aux_fix_backend_internal_classes(type(backend), this_local_dir) init_env.backend = None # to avoid to close the backend when init_env is deleted @@ -1037,8 +1049,6 @@ def make_from_dataset_path( # new in 1.11.0 if _overload_name_multimix is not None: # case of multimix - _add_to_name = '' # already defined in the first mix - name_env = _overload_name_multimix.name_env if _overload_name_multimix.mix_id >= 1 and _overload_name_multimix.local_dir_tmpfolder is not None: # this is not the first mix # for the other mix I need to read the data from files and NOT @@ -1049,41 +1059,11 @@ def make_from_dataset_path( # Finally instantiate env from config & overrides # including (if activated the new grid2op behaviour) env = Environment( - init_env_path=os.path.abspath(dataset_path), - init_grid_path=grid_path_abs, - chronics_handler=data_feeding, - backend=backend, - parameters=param, - name=name_env + _add_to_name, - names_chronics_to_backend=names_chronics_to_backend, - actionClass=action_class, - observationClass=observation_class, - rewardClass=reward_class, - legalActClass=gamerules_class, - voltagecontrolerClass=volagecontroler_class, - other_rewards=other_rewards, - opponent_space_type=opponent_space_type, - opponent_action_class=opponent_action_class, - opponent_class=opponent_class, - opponent_init_budget=opponent_init_budget, - opponent_attack_duration=opponent_attack_duration, - opponent_attack_cooldown=opponent_attack_cooldown, - opponent_budget_per_ts=opponent_budget_per_ts, - opponent_budget_class=opponent_budget_class, - kwargs_opponent=kwargs_opponent, - has_attention_budget=has_attention_budget, - attention_budget_cls=attention_budget_class, - kwargs_attention_budget=kwargs_attention_budget, - logger=logger, - n_busbar=n_busbar, # TODO n_busbar_per_sub different num per substations: read from a config file maybe (if not provided by the user) - _compat_glop_version=_compat_glop_version, + **default_kwargs, + chronics_handler=data_feeding, _read_from_local_dir=classes_path, _allow_loaded_backend=allow_loaded_backend, _local_dir_cls=this_local_dir, - _overload_name_multimix=_overload_name_multimix, - kwargs_observation=kwargs_observation, - observation_bk_class=observation_backend_class, - observation_bk_kwargs=observation_backend_kwargs ) if do_not_erase_cls is not None: env._do_not_erase_local_dir_cls = do_not_erase_cls diff --git a/grid2op/Observation/baseObservation.py b/grid2op/Observation/baseObservation.py index 1f9849aa..df727e19 100644 --- a/grid2op/Observation/baseObservation.py +++ b/grid2op/Observation/baseObservation.py @@ -30,7 +30,7 @@ NoForecastAvailable, BaseObservationError, ) -from grid2op.Space import GridObjects +from grid2op.Space import GridObjects, ElTypeInfo # TODO have a method that could do "forecast" by giving the _injection by the agent, # TODO if he wants to make custom forecasts @@ -417,6 +417,22 @@ class BaseObservation(GridObjects): - obs.attack_under_alert[i] = +1 => attackable line i has been attacked and (before the attack) an alert was sent (so your agent expects to "game over" within the next `env.parameters.ALERT_TIME_WINDOW` steps) + + gen_p_delta: :class:`numpy.ndarray`, dtype:float + + load_detached: :class:`numpy.ndarray`, dtype:bool + + gen_detached: :class:`numpy.ndarray`, dtype:bool + + storage_detached: :class:`numpy.ndarray`, dtype:bool + + load_p_detached: :class:`numpy.ndarray`, dtype:float + + load_q_detached: :class:`numpy.ndarray`, dtype:float + + gen_p_detached: :class:`numpy.ndarray`, dtype:float + + storage_p_detached: :class:`numpy.ndarray`, dtype:float _shunt_p: :class:`numpy.ndarray`, dtype:float Shunt active value (only available if shunts are available) (in MW) @@ -487,6 +503,16 @@ class BaseObservation(GridObjects): # gen up / down "gen_margin_up", "gen_margin_down", + # slack (>= 1.11.0) + "gen_p_delta", + # detachment (>= 1.11.0) + "load_detached", + "gen_detached", + "storage_detached", + "load_p_detached", + "load_q_detached", + "gen_p_detached", + "storage_p_detached", ] attr_list_vect = None @@ -620,6 +646,18 @@ def __init__(self, self.max_step = dt_int(np.iinfo(dt_int).max) self.delta_time = dt_float(5.0) + # slack (1.11.0) + self.gen_p_delta = np.empty(shape=cls.n_gen, dtype=dt_float) + + # detachment (>= 1.11.0) + self.load_detached = np.ones(shape=cls.n_load, dtype=dt_bool) + self.gen_detached = np.ones(shape=cls.n_gen, dtype=dt_bool) + self.storage_detached = np.ones(shape=cls.n_storage, dtype=dt_bool) + self.load_p_detached = np.zeros(shape=cls.n_load, dtype=dt_float) + self.load_q_detached = np.zeros(shape=cls.n_load, dtype=dt_float) + self.gen_p_detached = np.zeros(shape=cls.n_gen, dtype=dt_float) + self.storage_p_detached = np.zeros(shape=cls.n_storage, dtype=dt_float) + def _aux_copy(self, other : Self) -> None: attr_simple = [ "max_step", @@ -689,6 +727,16 @@ def _aux_copy(self, other : Self) -> None: "gen_margin_up", "gen_margin_down", "curtailment_limit_effective", + # slack (>= 1.11.0) + "gen_p_delta", + # detachment (>= 1.11.0) + "load_detached", + "gen_detached", + "storage_detached", + "load_p_detached", + "load_q_detached", + "gen_p_detached", + "storage_p_detached", ] if type(self).shunts_data_available: @@ -828,6 +876,12 @@ def state_of( - "theta" (optional) the voltage angle (in degree) of the bus to which the load is connected - "bus" on which bus the load is connected in the substation - "sub_id" the id of the substation to which the load is connected + - "detached" (>= 1.11.0) whether this load is detached from the grid + (if detachement is allowed in the environment) + - "p_detached" (>= 1.11.0) amount of MW detached from the grid cause by + the detachment of this load (if detachement is allowed in the environment) + - "q_detached" (>= 1.11.0) amount of MVAr detached from the grid cause by + the detachment of this load (if detachement is allowed in the environment) - if a generator is inspected, then the keys are: @@ -840,6 +894,21 @@ def state_of( - "actual_dispatch" the actual dispatch implemented for this generator - "target_dispatch" the target dispatch (cumulation of all previously asked dispatch by the agent) for this generator + - "curtailment": the curtailment applied on this generator (0. for non renewable generator) + - "curtailment_limit": the curtailment limit as given by the agent + - "curtailment_limit_effective": the effective curtailment limit + - "p_before_curtail": the active production (in MW) before any curtailment is applied + this should be 0. for non renewable generator + - "margin_up": by how much this generateur can see its production increase between this step + and the next (in MW). It's 0. for renewable generators. + - "margin_down": by how much this generateur can see its production decrease between this step + and the next (in MW). It's 0. for renewable generators. + - "p_delta" (>= 1.11.0) difference (in MW) between what the environment ask this generator to produce + and what it actually produces (difference is not caused by grid2op but by the Backend) + - "detached" (>= 1.11.0) whether this generator is detached from the + grid (if detachement is allowed in the environment) + - "p_detached" (>= 1.11.0) amount of MW detached from the grid cause by the + detachment of this generator (if detachement is allowed in the environment) - if a powerline is inspected then the keys are "origin" and "extremity" each being dictionary with keys: @@ -867,6 +936,11 @@ def state_of( - "storage_theta": (optional) the voltage angle of the bus at which the storage unit is connected - "bus": the bus (1 or 2) to which the storage unit is connected - "sub_id" : the id of the substation to which the sotrage unit is connected + - "detached" (>= 1.11.0) whether this storage is detached from the grid + (if detachement is allowed in the environment) + - "p_detached" (>= 1.11.0) amount of MW detached from the grid cause by the detachment of this storage + (if detachement is allowed in the environment) + - if a substation is inspected, it returns the topology to this substation in a dictionary with keys: @@ -929,6 +1003,10 @@ def state_of( } if self.support_theta: res["theta"] = self.load_theta[load_id] + if cls.detachment_is_allowed: + res["detached"] = self.load_detached[load_id] + res["p_detached"] = self.load_p_detached[load_id] + res["q_detached"] = self.load_q_detached[load_id] elif gen_id is not None: if ( line_id is not None @@ -959,9 +1037,13 @@ def state_of( "p_before_curtail": self.gen_p_before_curtail[gen_id], "margin_up": self.gen_margin_up[gen_id], "margin_down": self.gen_margin_down[gen_id], + "gen_p_delta": self.gen_p_delta[gen_id], } if self.support_theta: res["theta"] = self.gen_theta[gen_id] + if cls.detachment_is_allowed: + res["detached"] = self.gen_detached[gen_id] + res["p_detached"] = self.gen_p_detached[gen_id] elif line_id is not None: if substation_id is not None or storage_id is not None: raise Grid2OpException(ERROR_ONLY_SINGLE_EL) @@ -1028,6 +1110,9 @@ def state_of( res["sub_id"] = cls.storage_to_subid[storage_id] if self.support_theta: res["theta"] = self.storage_theta[storage_id] + if cls.detachment_is_allowed: + res["detached"] = self.storage_detached[storage_id] + res["p_detached"] = self.storage_p_detached[storage_id] else: if substation_id >= len(cls.sub_info): raise Grid2OpException( @@ -1054,7 +1139,7 @@ def state_of( return res @classmethod - def process_shunt_satic_data(cls) -> None: + def process_shunt_static_data(cls) -> None: if not cls.shunts_data_available: # this is really important, otherwise things from grid2op base types will be affected cls.attr_list_vect = copy.deepcopy(cls.attr_list_vect) @@ -1067,7 +1152,7 @@ def process_shunt_satic_data(cls) -> None: except ValueError: pass cls.attr_list_set = set(cls.attr_list_vect) - return super().process_shunt_satic_data() + return super().process_shunt_static_data() @classmethod def _aux_process_grid2op_compat_old(cls): @@ -1171,6 +1256,28 @@ def _aux_process_grid2op_compat_191(cls): except ValueError as exc_: # this attribute was not there in the first place pass + + @classmethod + def _aux_process_grid2op_compat_1_11_0(cls): + cls.attr_list_vect = copy.deepcopy(cls.attr_list_vect) + + for el in [ + # slack (>= 1.11.0) + "gen_p_delta", + # detachment (>= 1.11.0) + "load_detached", + "gen_detached", + "storage_detached", + "load_p_detached", + "load_q_detached", + "gen_p_detached", + "storage_p_detached", + ]: + try: + cls.attr_list_vect.remove(el) + except ValueError as exc_: + # this attribute was not there in the first place + pass @classmethod def process_grid2op_compat(cls) -> None: @@ -1201,6 +1308,10 @@ def process_grid2op_compat(cls) -> None: # alert attributes have been added in 1.9.1 cls._aux_process_grid2op_compat_191() + if glop_ver < cls.MIN_VERSION_DETACH: + # alert attributes have been added in 1.9.1 + cls._aux_process_grid2op_compat_1_11_0() + cls.attr_list_set = copy.deepcopy(cls.attr_list_set) cls.attr_list_set = set(cls.attr_list_vect) @@ -1326,6 +1437,19 @@ def reset(self) -> None: self.curtailment_limit_effective[:] = 0. self.curtailment[:] = 0. + # slack (>= 1.11.0) + self.gen_p_delta[:] = 0. + + # detachment (>= 1.11.0) + if type(self).detachment_is_allowed: + self.load_detached[:] = True + self.gen_detached[:] = True + self.storage_detached[:] = True + self.load_p_detached[:] = 0. + self.load_q_detached[:] = 0. + self.gen_p_detached[:] = 0. + self.storage_p_detached[:] = 0. + def set_game_over(self, env: Optional["grid2op.Environment.Environment"]=None) -> None: """ @@ -1463,6 +1587,19 @@ def set_game_over(self, # was_alert_used_after_attack not updated here in this case # attack_under_alert not updated here in this case + # slack (>= 1.11.0) + self.gen_p_delta[:] = 0. + + # detachment (>= 1.11.0) + if type(self).detachment_is_allowed: + self.load_detached[:] = True + self.gen_detached[:] = True + self.storage_detached[:] = True + self.load_p_detached[:] = 0. + self.load_q_detached[:] = 0. + self.gen_p_detached[:] = 0. + self.storage_p_detached[:] = 0. + def __compare_stats(self, other: Self, name: str) -> bool: attr_me = getattr(self, name) attr_other = getattr(other, name) @@ -2706,13 +2843,28 @@ def _aux_add_buses(self, graph, cls, first_id): return bus_ids def _aux_add_loads(self, graph, cls, first_id): + if type(self).detachment_is_allowed: + nodes_prop = [ + ("detached", self.load_detached), + ("p_detached", self.load_p_detached), + ("q_detached", self.load_q_detached), + ] + else: + nodes_prop = None + edges_prop=[ ("p", self.load_p), ("q", self.load_q), - ("v", self.load_v) + ("v", self.load_v), ] if self.support_theta: edges_prop.append(("theta", self.load_theta)) + + # slack (>= 1.11.0) + self.gen_p_delta[:] = 0. + + if "load_detached" in self.attr_list_set: + edges_prop.append(("is_detached", self.load_detached)) load_ids = self._aux_add_el_to_comp_graph(graph, first_id, cls.name_load, @@ -2720,7 +2872,7 @@ def _aux_add_loads(self, graph, cls, first_id): cls.n_load, self.load_bus, cls.load_to_subid, - nodes_prop=None, + nodes_prop=nodes_prop, edges_prop=edges_prop) return load_ids @@ -2732,8 +2884,9 @@ def _aux_add_gens(self, graph, cls, first_id): ("curtailment", self.curtailment), ("curtailment_limit", self.curtailment_limit), ("gen_margin_up", self.gen_margin_up), - ("gen_margin_down", self.gen_margin_down) - ] # todo class attributes gen_max_ramp_up etc. + ("gen_margin_down", self.gen_margin_down), + ("p_delta", self.gen_p_delta)] + # todo class attributes gen_max_ramp_up etc. edges_prop=[ ("p", - self.gen_p), ("q", - self.gen_q), @@ -2741,6 +2894,11 @@ def _aux_add_gens(self, graph, cls, first_id): ] if self.support_theta: edges_prop.append(("theta", self.gen_theta)) + + if type(self).detachment_is_allowed: + nodes_prop.append(("detached", self.gen_detached)) + nodes_prop.append(("p_detached", self.gen_p_detached)) + gen_ids = self._aux_add_el_to_comp_graph(graph, first_id, cls.name_gen, @@ -2754,11 +2912,17 @@ def _aux_add_gens(self, graph, cls, first_id): def _aux_add_storages(self, graph, cls, first_id): nodes_prop = [("storage_charge", self.storage_charge), - ("storage_power_target", self.storage_power_target)] + ("storage_power_target", self.storage_power_target), + ] + # TODO class attr in nodes_prop: storageEmax etc. edges_prop=[("p", self.storage_power)] if self.support_theta: edges_prop.append(("theta", self.storage_theta)) + + if type(self).detachment_is_allowed: + nodes_prop.append(("detached", self.storage_detached)) + nodes_prop.append(("p_detached", self.storage_p_detached)) sto_ids = self._aux_add_el_to_comp_graph(graph, first_id, cls.name_storage, @@ -3766,6 +3930,8 @@ def to_dict(self): # current_step / max step self._dictionnarized["current_step"] = self.current_step self._dictionnarized["max_step"] = self.max_step + + # TODO shedding: add relevant attributes return self._dictionnarized @@ -3965,7 +4131,7 @@ def add_act(self, act : "grid2op.Action.BaseAction", issue_warn=True) -> Self: cls = type(self) cls_act = type(act) - act = copy.deepcopy(act) + act : BaseAction = copy.deepcopy(act) res = cls() res.set_game_over(env=None) @@ -3977,8 +4143,13 @@ def add_act(self, act : "grid2op.Action.BaseAction", issue_warn=True) -> Self: raise RuntimeError( f"Impossible to add an ambiguous action to an observation. Your action was " f'ambiguous because: "{except_tmp}"' - ) + ) from except_tmp + if act._modif_detach_gen or act._modif_detach_load or act._modif_detach_storage: + raise NotImplementedError("This function is not yet implemented when some elements " + "are detached from the grid. Please write a feature request " + "if you are interested in this feature.") + # if a powerline has been reconnected without specific bus, i issue a warning if "set_line_status" in cls_act.authorized_keys: self._aux_add_act_set_line_status(cls, cls_act, act, res, issue_warn) @@ -4262,6 +4433,19 @@ def _update_obs_complete(self, env: "grid2op.Environment.BaseEnv", with_forecast self.curtailment_limit_effective[:] = 1.0 self.delta_time = dt_float(1.0 * env.delta_time_seconds / 60.0) + + # slack (1.11.0) + self.gen_p_delta[:] = env._delta_gen_p + + # detachment (>= 1.11.0) + if type(self).detachment_is_allowed: + self.load_detached[:] = env._loads_detached + self.gen_detached[:] = env._gens_detached + self.storage_detached[:] = env._storages_detached + self.load_p_detached[:] = env._load_p_detached + self.load_q_detached[:] = env._load_q_detached + self.gen_p_detached[:] = env._gen_p_detached + self.storage_p_detached[:] = env._storage_p_detached # handles forecasts here self._update_forecast(env, with_forecast) @@ -4852,58 +5036,6 @@ def get_back_to_ref_state( if self._is_done: raise Grid2OpException("Cannot use this function in a 'done' state.") return self.action_helper.get_back_to_ref_state(self, storage_setpoint, precision) - - def _aux_kcl(self, - n_el, # cst eg. cls.n_gen - el_to_subid, # cst eg. cls.gen_to_subid - el_bus, # cst eg. gen_bus - el_p, # cst, eg. gen_p - el_q, # cst, eg. gen_q - el_v, # cst, eg. gen_v - p_subs, q_subs, - p_bus, q_bus, - v_bus, - load_conv=True # whether the object is load convention (True) or gen convention (False) - ): - - # bellow i'm "forced" to do a loop otherwise, numpy do not compute the "+=" the way I want it to. - # for example, if two powerlines are such that line_or_to_subid is equal (eg both connected to substation 0) - # then numpy do not guarantee that `p_subs[self.line_or_to_subid] += p_or` will add the two "corresponding p_or" - # TODO this can be vectorized with matrix product, see example in obs.flow_bus_matrix (BaseObervation.py) - for i in range(n_el): - psubid = el_to_subid[i] - if el_bus[i] == -1: - # el is disconnected - continue - - # for substations - if load_conv: - p_subs[psubid] += el_p[i] - q_subs[psubid] += el_q[i] - else: - p_subs[psubid] -= el_p[i] - q_subs[psubid] -= el_q[i] - - # for bus - loc_bus = el_bus[i] - 1 - if load_conv: - p_bus[psubid, loc_bus] += el_p[i] - q_bus[psubid, loc_bus] += el_q[i] - else: - p_bus[psubid, loc_bus] -= el_p[i] - q_bus[psubid, loc_bus] -= el_q[i] - - # compute max and min values - if el_v is not None and el_v[i]: - # but only if gen is connected - v_bus[psubid, loc_bus][0] = min( - v_bus[psubid, loc_bus][0], - el_v[i], - ) - v_bus[psubid, loc_bus][1] = max( - v_bus[psubid, loc_bus][1], - el_v[i], - ) def check_kirchhoff(self) -> Tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray, np.ndarray]: """ @@ -4932,98 +5064,75 @@ def check_kirchhoff(self) -> Tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarra """ cls = type(self) - - # fist check the "substation law" : nothing is created at any substation - p_subs = np.zeros(cls.n_sub, dtype=dt_float) - q_subs = np.zeros(cls.n_sub, dtype=dt_float) - - # check for each bus - p_bus = np.zeros((cls.n_sub, cls.n_busbar_per_sub), dtype=dt_float) - q_bus = np.zeros((cls.n_sub, cls.n_busbar_per_sub), dtype=dt_float) - v_bus = ( - np.zeros((cls.n_sub, cls.n_busbar_per_sub, 2), dtype=dt_float) - 1.0 - ) # sub, busbar, [min,max] - some_kind_of_inf = 1_000_000_000. - v_bus[:,:,0] = some_kind_of_inf - v_bus[:,:,1] = -1 * some_kind_of_inf - - self._aux_kcl( - cls.n_line, # cst eg. cls.n_gen - cls.line_or_to_subid, # cst eg. cls.gen_to_subid - self.line_or_bus, + lineor_info = ElTypeInfo( + self.line_or_bus, # cst eg. self.gen_bus self.p_or, # cst, eg. gen_p self.q_or, # cst, eg. gen_q self.v_or, # cst, eg. gen_v - p_subs, q_subs, - p_bus, q_bus, - v_bus, ) - self._aux_kcl( - cls.n_line, # cst eg. cls.n_gen - cls.line_ex_to_subid, # cst eg. cls.gen_to_subid - self.line_ex_bus, + lineex_info = ElTypeInfo( + self.line_ex_bus, # cst eg. self.gen_bus self.p_ex, # cst, eg. gen_p self.q_ex, # cst, eg. gen_q self.v_ex, # cst, eg. gen_v - p_subs, q_subs, - p_bus, q_bus, - v_bus, ) - self._aux_kcl( - cls.n_load, # cst eg. cls.n_gen - cls.load_to_subid, # cst eg. cls.gen_to_subid - self.load_bus, + load_info = ElTypeInfo( + self.load_bus, # cst eg. self.gen_bus self.load_p, # cst, eg. gen_p self.load_q, # cst, eg. gen_q self.load_v, # cst, eg. gen_v - p_subs, q_subs, - p_bus, q_bus, - v_bus, ) - self._aux_kcl( - cls.n_gen, # cst eg. cls.n_gen - cls.gen_to_subid, # cst eg. cls.gen_to_subid + gen_info = ElTypeInfo( self.gen_bus, # cst eg. self.gen_bus self.gen_p, # cst, eg. gen_p self.gen_q, # cst, eg. gen_q self.gen_v, # cst, eg. gen_v - p_subs, q_subs, - p_bus, q_bus, - v_bus, - load_conv=False ) - if cls.n_storage: - self._aux_kcl( - cls.n_storage, # cst eg. cls.n_gen - cls.storage_to_subid, # cst eg. cls.gen_to_subid - self.storage_bus, + if cls.n_storage > 0: + storage_info = ElTypeInfo( + self.storage_bus, # cst eg. self.gen_bus self.storage_power, # cst, eg. gen_p np.zeros(cls.n_storage), # cst, eg. gen_q None, # cst, eg. gen_v - p_subs, q_subs, - p_bus, q_bus, - v_bus, - ) + ) + else: + storage_info = None if cls.shunts_data_available: - self._aux_kcl( - cls.n_shunt, # cst eg. cls.n_gen - cls.shunt_to_subid, # cst eg. cls.gen_to_subid - self._shunt_bus, + shunt_info = ElTypeInfo( + self._shunt_bus, # cst eg. self.gen_bus self._shunt_p, # cst, eg. gen_p self._shunt_q, # cst, eg. gen_q self._shunt_v, # cst, eg. gen_v - p_subs, q_subs, - p_bus, q_bus, - v_bus, - ) - else: - warnings.warn( - "Observation.check_kirchhoff Impossible to get shunt information. Reactive information might be " - "incorrect." ) - diff_v_bus = np.zeros((cls.n_sub, cls.n_busbar_per_sub), dtype=dt_float) - diff_v_bus[:, :] = v_bus[:, :, 1] - v_bus[:, :, 0] - diff_v_bus[np.abs(diff_v_bus - -2. * some_kind_of_inf) <= 1e-5 ] = 0. # disconnected bus + else: + shunt_info = None + + p_subs, q_subs, p_bus, q_bus, diff_v_bus = cls._aux_check_kirchhoff(lineor_info, + lineex_info, + load_info, + gen_info, + storage_info, + shunt_info) return p_subs, q_subs, p_bus, q_bus, diff_v_bus - \ No newline at end of file + + @classmethod + def process_detachment(cls): + if not cls.detachment_is_allowed: + # this is really important, otherwise things from grid2op base types will be affected + cls.attr_list_vect = copy.deepcopy(cls.attr_list_vect) + # remove the detachment from the list to vector + for el in ["load_detached", + "gen_detached", + "storage_detached", + "load_p_detached", + "load_q_detached", + "gen_p_detached", + "storage_p_detached",]: + if el in cls.attr_list_vect: + try: + cls.attr_list_vect.remove(el) + except ValueError: + pass + cls._update_value_set() + return super().process_detachment() diff --git a/grid2op/Observation/completeObservation.py b/grid2op/Observation/completeObservation.py index 201e94f0..31da50cb 100644 --- a/grid2op/Observation/completeObservation.py +++ b/grid2op/Observation/completeObservation.py @@ -195,6 +195,16 @@ class CompleteObservation(BaseObservation): "total_number_of_alert", "time_since_last_attack", "was_alert_used_after_attack", + # slack (>= 1.11.0) + "gen_p_delta", + # detachment (>= 1.11.0) + "load_detached", + "gen_detached", + "storage_detached", + "load_p_detached", + "load_q_detached", + "gen_p_detached", + "storage_p_detached", ] attr_list_json = [ "_thermal_limit", diff --git a/grid2op/Observation/observationSpace.py b/grid2op/Observation/observationSpace.py index 5b4a00d9..1bc0290a 100644 --- a/grid2op/Observation/observationSpace.py +++ b/grid2op/Observation/observationSpace.py @@ -202,6 +202,7 @@ def _create_obs_env(self, env, observationClass): _ptr_orig_obs_space=self, _local_dir_cls=env._local_dir_cls, _read_from_local_dir=env._read_from_local_dir, + allow_detachment=type(env.backend).detachment_is_allowed ) for k, v in self.obs_env.other_rewards.items(): v.initialize(self.obs_env) diff --git a/grid2op/Opponent/opponentSpace.py b/grid2op/Opponent/opponentSpace.py index bca588d4..9a1583be 100644 --- a/grid2op/Opponent/opponentSpace.py +++ b/grid2op/Opponent/opponentSpace.py @@ -241,6 +241,11 @@ def attack(self, observation, agent_action, env_action): attack_duration = self.current_attack_duration if attack is None: attack_duration = 0 + else: + # NB : here observation is not None (check done first line of this function) + # cache the get_topological_impact to avoid useless computation later + # this is a speed optimization + _ = attack.get_topological_impact(observation.line_status, _store_in_cache=True, _read_from_cache=False) return attack, attack_duration def close(self): diff --git a/grid2op/Parameters.py b/grid2op/Parameters.py index c5ec67b2..6d5adcf8 100644 --- a/grid2op/Parameters.py +++ b/grid2op/Parameters.py @@ -161,6 +161,42 @@ class Parameters: `env.reset(options={"init state": ...})` (see doc of :func:`grid2op.Environment.Environment.reset` for more information) + ENV_DOES_REDISPATCHING: ``bool`` + Whether to let the environment do the redispatching instead of relying on + the backend internal implementation (usually done with the "slack buses"). + + It is ``True`` by default and for all grid2op environment before version 1.11.0 + + .. versionadded: 1.11.0 + + .. note:: + Disabling this feature might speed up the `env.step` computation, but the result + will be highly dependant on the backend (for example results might differ + between using lightsim2grid or pypowsybl2grid) and more importantly between the + parameters of the backend + + Setting this parameter to `False` is not advised if your agent really depends on redispatching, curtailment + or actions on storage units OR if your backend does not have a distributed slack (at least). (note + that we do not recommend to use it even if your backend has a distributed slack). + + Furthermore, if you consider doing a lot of "detachment" / "shedding" / "curtailment" + on the loads or generator, it's best to avoid setting this flag to `False`. + + If you don't know what a "distributed slack" is you probably should not set this to `False`. + + And to be somewhat realistic, you might also consider setting the flag + :attr:`Parameters.STOP_EP_IF_SLACK_BREAK_CONSTRAINTS` to avoid simulating unrealistic + episode. + + STOP_EP_IF_SLACK_BREAK_CONSTRAINTS: ``bool`` + Whether to stop the episode when a constraint on the slack generator(s) are violated. + + In grid2op < 1.11.0 these were not checked at all. But from grid2op 1.11.0 you have the option + to check whether some slack generators would absorb / produce too much between two consecutive + steps which would be unrealistic in practice. + + It defaults to ``False``. + """ def __init__(self, parameters_path=None): @@ -242,6 +278,10 @@ def __init__(self, parameters_path=None): warnings.warn(warn_msg.format(parameters_path)) self.IGNORE_INITIAL_STATE_TIME_SERIE = False + + # Added in 1.11.0 with detachement + self.ENV_DOES_REDISPATCHING = True + self.STOP_EP_IF_SLACK_BREAK_CONSTRAINTS = False @staticmethod def _isok_txt(arg): @@ -387,7 +427,12 @@ def init_from_dict(self, dict_): self.IGNORE_INITIAL_STATE_TIME_SERIE = Parameters._isok_txt( dict_["IGNORE_INITIAL_STATE_TIME_SERIE"] ) - + + if "ENV_DOES_REDISPATCHING" in dict_: + self.ENV_DOES_REDISPATCHING = Parameters._isok_txt(dict_["ENV_DOES_REDISPATCHING"]) + if "STOP_EP_IF_SLACK_BREAK_CONSTRAINTS" in dict_: + self.STOP_EP_IF_SLACK_BREAK_CONSTRAINTS = Parameters._isok_txt(dict_["STOP_EP_IF_SLACK_BREAK_CONSTRAINTS"]) + authorized_keys = set(self.__dict__.keys()) authorized_keys = authorized_keys | { "NB_TIMESTEP_POWERFLOW_ALLOWED", @@ -437,6 +482,8 @@ def to_dict(self): res["MAX_SIMULATE_PER_STEP"] = int(self.MAX_SIMULATE_PER_STEP) res["MAX_SIMULATE_PER_EPISODE"] = int(self.MAX_SIMULATE_PER_EPISODE) res["IGNORE_INITIAL_STATE_TIME_SERIE"] = int(self.IGNORE_INITIAL_STATE_TIME_SERIE) + res["ENV_DOES_REDISPATCHING"] = bool(self.ENV_DOES_REDISPATCHING) + res["STOP_EP_IF_SLACK_BREAK_CONSTRAINTS"] = bool(self.STOP_EP_IF_SLACK_BREAK_CONSTRAINTS) return res def init_from_json(self, json_path): @@ -741,3 +788,20 @@ def check_valid(self): raise RuntimeError( f'Impossible to convert IGNORE_INITIAL_STATE_TIME_SERIE to bool with error \n:"{exc_}"' ) from exc_ + + try: + if not isinstance(self.ENV_DOES_REDISPATCHING, (bool, dt_bool)): + raise RuntimeError("ENV_DOES_REDISPATCHING should be a boolean") + self.ENV_DOES_REDISPATCHING = dt_bool(self.ENV_DOES_REDISPATCHING) + except Exception as exc_: + raise RuntimeError( + f'Impossible to convert ENV_DOES_REDISPATCHING to bool with error \n:"{exc_}"' + ) from exc_ + try: + if not isinstance(self.STOP_EP_IF_SLACK_BREAK_CONSTRAINTS, (bool, dt_bool)): + raise RuntimeError("STOP_EP_IF_SLACK_BREAK_CONSTRAINTS should be a boolean") + self.STOP_EP_IF_SLACK_BREAK_CONSTRAINTS = dt_bool(self.STOP_EP_IF_SLACK_BREAK_CONSTRAINTS) + except Exception as exc_: + raise RuntimeError( + f'Impossible to convert STOP_EP_IF_SLACK_BREAK_CONSTRAINTS to bool with error \n:"{exc_}"' + ) from exc_ diff --git a/grid2op/Runner/runner.py b/grid2op/Runner/runner.py index a16a66b6..9ee0e28c 100644 --- a/grid2op/Runner/runner.py +++ b/grid2op/Runner/runner.py @@ -29,7 +29,7 @@ from grid2op.dtypes import dt_float from grid2op.Opponent import BaseOpponent, NeverAttackBudget from grid2op.operator_attention import LinearAttentionBudget -from grid2op.Space import DEFAULT_N_BUSBAR_PER_SUB +from grid2op.Space import DEFAULT_N_BUSBAR_PER_SUB, DEFAULT_ALLOW_DETACHMENT from grid2op.Episode import EpisodeData # on windows if i start using sequential, i need to continue using sequential # if i start using parallel i need to continue using parallel @@ -354,6 +354,7 @@ def __init__( init_grid_path: str, path_chron, # path where chronics of injections are stored n_busbar=DEFAULT_N_BUSBAR_PER_SUB, + allow_detachment=DEFAULT_ALLOW_DETACHMENT, name_env="unknown", parameters_path=None, names_chronics_to_backend=None, @@ -466,6 +467,7 @@ def __init__( # TOOD doc for the attention budget """ self._n_busbar = n_busbar + self._allow_detachment = allow_detachment self.with_forecast = with_forecast self.name_env = name_env self._overload_name_multimix = _overload_name_multimix @@ -765,6 +767,7 @@ def _new_env(self, parameters) -> Tuple[BaseEnv, BaseAgent]: res = self.envClass.init_obj_from_kwargs( other_env_kwargs=self.other_env_kwargs, n_busbar=self._n_busbar, + allow_detachment=self._allow_detachment, init_env_path=self.init_env_path, init_grid_path=self.init_grid_path, chronics_handler=chronics_handler, @@ -1301,6 +1304,7 @@ def _get_params(self): "_overload_name_multimix": self._overload_name_multimix, "other_env_kwargs": self.other_env_kwargs, "n_busbar": self._n_busbar, + "allow_detachment": self._allow_detachment, "mp_context": None, # this is used in multi processing context, avoid to multi process a multi process stuff "_local_dir_cls": self._local_dir_cls, } diff --git a/grid2op/Space/GridObjects.py b/grid2op/Space/GridObjects.py index c69f1291..2c40db3f 100644 --- a/grid2op/Space/GridObjects.py +++ b/grid2op/Space/GridObjects.py @@ -23,18 +23,20 @@ import numpy as np import sys from packaging import version -from typing import Dict, Union, Literal, Any, List, Optional, ClassVar, Tuple +from typing import Dict, Type, Union, Literal, Any, List, Optional, ClassVar, Tuple import grid2op from grid2op.dtypes import dt_int, dt_float, dt_bool from grid2op.typing_variables import CLS_AS_DICT_TYPING, N_BUSBAR_PER_SUB_TYPING from grid2op.Exceptions import * -from grid2op.Space.space_utils import extract_from_dict, save_to_dict +from grid2op.Space.space_utils import extract_from_dict, save_to_dict, ElTypeInfo # TODO tests of these methods and this class in general DEFAULT_N_BUSBAR_PER_SUB = 2 +DEFAULT_ALLOW_DETACHMENT = False GRID2OP_CLASSES_ENV_FOLDER = "_grid2op_classes" + class GridObjects: """ INTERNAL @@ -477,6 +479,7 @@ class GridObjects: """ BEFORE_COMPAT_VERSION : ClassVar[str] = "neurips_2020_compat" + MIN_VERSION_DETACH : ClassVar[str] = version.parse("1.11.0.dev2") glop_version : ClassVar[str] = grid2op.__version__ _INIT_GRID_CLS = None # do not modify that, this is handled by grid2op automatically @@ -513,6 +516,7 @@ class GridObjects: sub_info : ClassVar[np.ndarray] = None dim_topo : ClassVar[np.ndarray] = -1 + detachment_is_allowed : ClassVar[bool] = DEFAULT_ALLOW_DETACHMENT # to which substation is connected each element load_to_subid : ClassVar[np.ndarray] = None @@ -641,6 +645,10 @@ def set_n_busbar_per_sub(cls, n_busbar_per_sub: N_BUSBAR_PER_SUB_TYPING) -> None # TODO n_busbar_per_sub different num per substations cls.n_busbar_per_sub = n_busbar_per_sub + @classmethod + def set_detachment_is_allowed(cls, detachment_is_allowed: bool) -> None: + cls.detachment_is_allowed = detachment_is_allowed + @classmethod def tell_dim_alarm(cls, dim_alarms: int) -> None: if cls.dim_alarms != 0: @@ -683,8 +691,10 @@ def _clear_class_attribute(cls) -> None: This clear the class as if it was defined in grid2op directly. """ + + #: this has to be here and not in _clear_grid_dependant_class_attributes + # otherwise it breaks some lightsim2grid versions cls.shunts_data_available = False - cls.n_busbar_per_sub = DEFAULT_N_BUSBAR_PER_SUB # for redispatching / unit commitment cls._li_attr_disp = [ @@ -716,6 +726,13 @@ def _clear_class_attribute(cls) -> None: float, bool, ] + + cls.SUB_COL = 0 + cls.LOA_COL = 1 + cls.GEN_COL = 2 + cls.LOR_COL = 3 + cls.LEX_COL = 4 + cls.STORAGE_COL = 5 cls._clear_grid_dependant_class_attributes() @@ -726,15 +743,11 @@ def _clear_grid_dependant_class_attributes(cls) -> None: cls._INIT_GRID_CLS = None # do not modify that, this is handled by grid2op automatically cls._PATH_GRID_CLASSES = None # especially do not modify that + cls.n_busbar_per_sub = DEFAULT_N_BUSBAR_PER_SUB + cls.detachment_is_allowed = DEFAULT_ALLOW_DETACHMENT + cls.glop_version = grid2op.__version__ - cls.SUB_COL = 0 - cls.LOA_COL = 1 - cls.GEN_COL = 2 - cls.LOR_COL = 3 - cls.LEX_COL = 4 - cls.STORAGE_COL = 5 - cls.attr_list_vect = None cls.attr_list_set = {} cls.attr_list_json = [] @@ -893,6 +906,20 @@ def _get_array_from_attr_name(self, attr_name: str) -> Union[np.ndarray, int, st """ return np.array(getattr(self, attr_name)).flatten() + def _set_array_from_attr_name(self, allowed_keys, key: str, array_) -> None: + """used for `from_json` please see `_assign_attr_from_name` for `from_vect`""" + if key not in allowed_keys: + raise AmbiguousAction(f'Impossible to recognize the key "{key}"') + my_attr = getattr(self, key) + if isinstance(my_attr, np.ndarray): + # the regular instance is an array, so i just need to assign the right values to it + my_attr[:] = array_ + else: + # normal values is a scalar. So i need to convert the array received as a scalar, and + # convert it to the proper type + type_ = type(my_attr) + setattr(self, key, type_(array_[0])) + def to_vect(self) -> np.ndarray: """ Convert this instance of GridObjects to a numpy ndarray. @@ -988,19 +1015,10 @@ def from_json(self, dict_: Dict[str, Any]) -> None: """ # TODO optimization for action or observation, to reduce json size, for example using the see `to_json` - all_keys = type(self).attr_list_vect + type(self).attr_list_json + cls = type(self) + all_keys = cls.attr_list_vect + cls.attr_list_json for key, array_ in dict_.items(): - if key not in all_keys: - raise AmbiguousAction(f'Impossible to recognize the key "{key}"') - my_attr = getattr(self, key) - if isinstance(my_attr, np.ndarray): - # the regular instance is an array, so i just need to assign the right values to it - my_attr[:] = array_ - else: - # normal values is a scalar. So i need to convert the array received as a scalar, and - # convert it to the proper type - type_ = type(my_attr) - setattr(self, key, type_(array_[0])) + self._set_array_from_attr_name(all_keys, key, array_) @classmethod def _convert_to_json(cls, dict_: Dict[str, Any]) -> None: @@ -1121,6 +1139,8 @@ def _assign_attr_from_name(self, attr_nm, vect): If this function is overloaded, then the _get_array_from_attr_name must be too. + Used for `from_vect`, please see `_set_array_from_attr_name` for `from_json` + Parameters ---------- attr_nm @@ -2031,17 +2051,18 @@ def assert_grid_correct_cls(cls): # TODO n_busbar_per_sub different num per substations if isinstance(cls.n_busbar_per_sub, (int, dt_int, np.int32, np.int64)): cls.n_busbar_per_sub = dt_int(cls.n_busbar_per_sub) - # np.full(cls.n_sub, - # fill_value=cls.n_busbar_per_sub, - # dtype=dt_int) else: - # cls.n_busbar_per_sub = np.array(cls.n_busbar_per_sub) - # cls.n_busbar_per_sub = cls.n_busbar_per_sub.astype(dt_int) - raise EnvError("Grid2op cannot handle a different number of busbar per substations at the moment.") + raise EnvError("Grid2op cannot handle a different number " + "of busbar per substations with provided input " + "(make sure `n_busbar_per_sub` is an int)") + + if isinstance(cls.detachment_is_allowed, (bool, dt_bool)): + cls.detachment_is_allowed = dt_bool(cls.detachment_is_allowed) + else: + raise EnvError("Grid2op cannot handle disconnection of loads / generators " + "at the moment (make sure `detachment_is_allowed` " + "is a bool)") - # if cls.n_busbar_per_sub != int(cls.n_busbar_per_sub): - # raise EnvError(f"`n_busbar_per_sub` should be convertible to an integer, found {cls.n_busbar_per_sub}") - # cls.n_busbar_per_sub = int(cls.n_busbar_per_sub) if (cls.n_busbar_per_sub < 1).any(): raise EnvError(f"`n_busbar_per_sub` should be >= 1 found {cls.n_busbar_per_sub}") @@ -2944,7 +2965,7 @@ def init_grid(cls, gridobj, force=False, extra_name=None, force_module=None, _lo it does not initialize it. Setting "force=True" will bypass this check and update it accordingly. """ - # nothing to do now that the value are class member + # nothing to do now that the value are class member name_res = "{}_{}".format(cls.__name__, gridobj.env_name) if gridobj.glop_version != grid2op.__version__: name_res += f"_{gridobj.glop_version}" @@ -2968,6 +2989,9 @@ def init_grid(cls, gridobj, force=False, extra_name=None, force_module=None, _lo # to be able to load same environment with # different `n_busbar_per_sub` name_res += f"_{gridobj.n_busbar_per_sub}" + + if gridobj.detachment_is_allowed != DEFAULT_ALLOW_DETACHMENT: + name_res += "_allowDetach" if _local_dir_cls is not None and gridobj._PATH_GRID_CLASSES is not None: # new in grid2op 1.10.3: @@ -3009,12 +3033,12 @@ def init_grid(cls, gridobj, force=False, extra_name=None, force_module=None, _lo else: # i am the original class from grid2op res_cls._INIT_GRID_CLS = cls - res_cls._IS_INIT = True res_cls._compute_pos_big_topo_cls() - res_cls.process_shunt_satic_data() + res_cls.process_shunt_static_data() compat_mode = res_cls.process_grid2op_compat() + res_cls.process_detachment() res_cls._check_convert_to_np_array() # convert everything to numpy array if force_module is not None: res_cls.__module__ = force_module # hack because otherwise it says "abc" which is not the case @@ -3085,6 +3109,12 @@ def process_grid2op_compat(cls): # I need to set it to the default if set elsewhere cls.n_busbar_per_sub = DEFAULT_N_BUSBAR_PER_SUB res = True + + if glop_ver < cls.MIN_VERSION_DETACH: + # Detachment did not exist, default value should have + # no effect + cls.detachment_is_allowed = DEFAULT_ALLOW_DETACHMENT + res = True if res: cls._reset_cls_dict() # forget the previous class (stored as dict) @@ -3736,6 +3766,8 @@ def _make_cls_dict(cls, res, as_list=True, copy_=True, _topo_vect_only=False): else: res[k] = v return + + save_to_dict(res, cls, "detachment_is_allowed", str, copy_) if not _topo_vect_only: # all the attributes bellow are not needed for the "first call" @@ -4060,6 +4092,7 @@ def _make_cls_dict(cls, res, as_list=True, copy_=True, _topo_vect_only=False): save_to_dict( res, cls, "alertable_line_ids", (lambda li: [int(el) for el in li]) if as_list else None, copy_ ) + # avoid further computation and save it if not as_list: cls._CLS_DICT = res.copy() @@ -4100,7 +4133,7 @@ def _make_cls_dict_extended(cls, res: CLS_AS_DICT_TYPING, as_list=True, copy_=Tr # shunt (not in topo vect but might be usefull) res["shunts_data_available"] = cls.shunts_data_available res["n_shunt"] = cls.n_shunt - + if not _topo_vect_only: # all the attributes bellow are not needed for the "first call" # to this function when the elements are put together in the topo_vect. @@ -4114,7 +4147,9 @@ def _make_cls_dict_extended(cls, res: CLS_AS_DICT_TYPING, as_list=True, copy_=Tr # n_busbar_per_sub res["n_busbar_per_sub"] = cls.n_busbar_per_sub - + + res["detachment_is_allowed"] = cls.detachment_is_allowed + # avoid further computation and save it if not as_list and not _topo_vect_only: cls._CLS_DICT_EXTENDED = res.copy() @@ -4187,6 +4222,18 @@ class res(GridObjects): cls._PATH_GRID_CLASSES = None else: cls._PATH_GRID_CLASSES = None + + # Detachment of Loads / Generators + if 'detachment_is_allowed' in dict_: + if dict_["detachment_is_allowed"] == "True": + cls.detachment_is_allowed = True + elif dict_["detachment_is_allowed"] == "False": + cls.detachment_is_allowed = False + else: + raise ValueError(f"'detachment_is_allowed' (value: {dict_['detachment_is_allowed']}'')" + "could not be converted to Boolean ") + else: # Compatibility for older versions + cls.detachment_is_allowed = DEFAULT_ALLOW_DETACHMENT if 'n_busbar_per_sub' in dict_: cls.n_busbar_per_sub = int(dict_["n_busbar_per_sub"]) @@ -4360,7 +4407,7 @@ class res(GridObjects): # backward compatibility: no storage were supported cls.set_no_storage() - cls.process_shunt_satic_data() + cls.process_shunt_static_data() if cls.glop_version != grid2op.__version__: # change name of the environment, this is done in Environment.py for regular environment @@ -4368,7 +4415,9 @@ class res(GridObjects): # cls.set_env_name(f"{cls.env_name}_{cls.glop_version}") # and now post process the class attributes for that cls.process_grid2op_compat() - + + cls.process_detachment() + if "assistant_warning_type" in dict_: cls.assistant_warning_type = dict_["assistant_warning_type"] else: @@ -4408,10 +4457,17 @@ class res(GridObjects): return cls() @classmethod - def process_shunt_satic_data(cls): + def process_shunt_static_data(cls): """remove possible shunts data from the classes, if shunts are deactivated""" pass + @classmethod + def process_detachment(cls): + """process the status of detachment, that can be turned on or off, is overloaded for :class:`grid2op.Action.BaseAction` + or :class:`grid2op.Observation.BaseObservation` + """ + pass + @classmethod def set_no_storage(cls): """ @@ -4500,11 +4556,14 @@ def _build_cls_from_import(name_cls, path_env): try: module = importlib.import_module(GRID2OP_CLASSES_ENV_FOLDER) if hasattr(module, name_cls): - my_class = getattr(module, name_cls) + my_class : Type["GridObjects"] = getattr(module, name_cls) except (ModuleNotFoundError, ImportError) as exc_: # normal behaviour i don't do anything there # TODO explain why pass + my_class.process_grid2op_compat() + my_class.process_detachment() + my_class.process_shunt_static_data() return my_class @staticmethod @@ -4540,7 +4599,8 @@ def init_grid_from_dict_for_pickle(name_res, orig_cls, cls_attr): res_cls._compute_pos_big_topo_cls() if res_cls.glop_version != grid2op.__version__: res_cls.process_grid2op_compat() - res_cls.process_shunt_satic_data() + res_cls.process_shunt_static_data() + res_cls.process_detachment() # add the class in the "globals" for reuse later globals()[name_res] = res_cls @@ -4956,7 +5016,7 @@ class {cls.__name__}({cls._INIT_GRID_CLS.__name__}): sub_info = {sub_info_str} dim_topo = {cls.dim_topo} - + # to which substation is connected each element load_to_subid = {load_to_subid_str} gen_to_subid = {gen_to_subid_str} @@ -5043,6 +5103,9 @@ class {cls.__name__}({cls._INIT_GRID_CLS.__name__}): alertable_line_names = {alertable_line_names_str} alertable_line_ids = {alertable_line_ids_str} + # shedding + detachment_is_allowed = {cls.detachment_is_allowed} + """ return res @@ -5229,3 +5292,214 @@ def get_storage_info(cls, *, storage_id : Optional[int]=None, storage_name : Opt obj_name=storage_name) sub_id = cls.storage_to_subid[storage_id] return storage_id, storage_name, sub_id + + + @classmethod + def _aux_kcl_eltype(cls, + n_el : int, # cst eg. cls.n_gen + el_to_subid : np.ndarray, # cst eg. cls.gen_to_subid + el_bus : np.ndarray, # cst eg. gen_bus + el_p : np.ndarray, # cst, eg. gen_p + el_q : np.ndarray, # cst, eg. gen_q + el_v : np.ndarray, # cst, eg. gen_v + p_subs : np.ndarray, q_subs : np.ndarray, + p_bus : np.ndarray, q_bus : np.ndarray, + v_bus : np.ndarray, + load_conv: bool=True # whether the object is load convention (True) or gen convention (False) + ): + """This function is used as an auxilliary function in the function :func:`grid2op.Observation.BaseObservation.check_kirchhoff` + and :func:`grid2op.Backend.Backend.check_kirchhoff` + + Parameters + ---------- + n_el : int + number of this element type (*eg* cls.n_gen) + el_to_subid : np.ndarray + for each element of this element type, on which substation this element is connected (*eg* cls.gen_to_subid) + el_bus : np.ndarray + for each element of this element type, on which busbar this element is connected (*eg* obs.gen_bus) + el_p : np.ndarray + for each element of this element type, it gives the active power value consumed / produced by this element (*eg* obs.gen_p) + el_q : np.ndarray + for each element of this element type, it gives the reactive power value consumed / produced by this element (*eg* obs.gen_q) + el_v : np.ndarray + for each element of this element type, it gives the voltage magnitude of the bus to which this element (*eg* obs.gen_v) + is connected + p_subs : np.ndarray + results, modified in place: p absorbed at each substation + q_subs : np.ndarray + results, modified in place: q absorbed at each substation + p_bus : np.ndarray + results, modified in place: p absorbed at each bus (shape nb_sub, max_nb_busbar_per_sub) + q_bus : np.ndarray + results, modified in place: q absorbed at each bus (shape nb_sub, max_nb_busbar_per_sub) + v_bus : np.ndarray + results, modified in place: min voltage and max voltage found per bus (shape nb_sub, max_nb_busbar_per_sub, 2) + load_conv : _type_, optional + Whetherthis object type use the "load convention" or "generator convention" for p and q, by default True + """ + # bellow i'm "forced" to do a loop otherwise, numpy do not compute the "+=" the way I want it to. + # for example, if two powerlines are such that line_or_to_subid is equal (eg both connected to substation 0) + # then numpy do not guarantee that `p_subs[self.line_or_to_subid] += p_or` will add the two "corresponding p_or" + # TODO this can be vectorized with matrix product, see example in obs.flow_bus_matrix (BaseObervation.py) + for i in range(n_el): + psubid = el_to_subid[i] + if el_bus[i] == -1: + # el is disconnected + continue + + # for substations + if load_conv: + p_subs[psubid] += el_p[i] + q_subs[psubid] += el_q[i] + else: + p_subs[psubid] -= el_p[i] + q_subs[psubid] -= el_q[i] + + # for bus + loc_bus = el_bus[i] - 1 + if load_conv: + p_bus[psubid, loc_bus] += el_p[i] + q_bus[psubid, loc_bus] += el_q[i] + else: + p_bus[psubid, loc_bus] -= el_p[i] + q_bus[psubid, loc_bus] -= el_q[i] + + # compute max and min values + if el_v is not None and el_v[i]: + # but only if gen is connected + v_bus[psubid, loc_bus][0] = min( + v_bus[psubid, loc_bus][0], + el_v[i], + ) + v_bus[psubid, loc_bus][1] = max( + v_bus[psubid, loc_bus][1], + el_v[i], + ) + + @classmethod + def _aux_check_kirchhoff(cls, + lineor_info : ElTypeInfo, + lineex_info: ElTypeInfo, + load_info: ElTypeInfo, + gen_info: ElTypeInfo, + storage_info: Optional[ElTypeInfo] = None, + shunt_info : Optional[ElTypeInfo] = None, + ) -> Tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray, np.ndarray]: + """ + Analogous to "backend.check_kirchhoff" but from the observation + + .. versionadded:: 1.11.0 + + Returns + ------- + p_subs ``numpy.ndarray`` + sum of injected active power at each substations (MW) + q_subs ``numpy.ndarray`` + sum of injected reactive power at each substations (MVAr) + p_bus ``numpy.ndarray`` + sum of injected active power at each buses. It is given in form of a matrix, with number of substations as + row, and number of columns equal to the maximum number of buses for a substation (MW) + q_bus ``numpy.ndarray`` + sum of injected reactive power at each buses. It is given in form of a matrix, with number of substations as + row, and number of columns equal to the maximum number of buses for a substation (MVAr) + diff_v_bus: ``numpy.ndarray`` (2d array) + difference between maximum voltage and minimum voltage (computed for each elements) + at each bus. It is an array of two dimension: + + - first dimension represents the the substation (between 1 and self.n_sub) + - second element represents the busbar in the substation (0 or 1 usually) + + """ + # fist check the "substation law" : nothing is created at any substation + p_subs = np.zeros(cls.n_sub, dtype=dt_float) + q_subs = np.zeros(cls.n_sub, dtype=dt_float) + + # check for each bus + p_bus = np.zeros((cls.n_sub, cls.n_busbar_per_sub), dtype=dt_float) + q_bus = np.zeros((cls.n_sub, cls.n_busbar_per_sub), dtype=dt_float) + v_bus = ( + np.zeros((cls.n_sub, cls.n_busbar_per_sub, 2), dtype=dt_float) - 1.0 + ) # sub, busbar, [min,max] + some_kind_of_inf = 1_000_000_000. + v_bus[:,:,0] = some_kind_of_inf + v_bus[:,:,1] = -1 * some_kind_of_inf + cls._aux_kcl_eltype( + cls.n_line, # cst eg. cls.n_gen + cls.line_or_to_subid, # cst eg. cls.gen_to_subid + lineor_info._bus, # cst eg. self.gen_bus + lineor_info._p, # cst, eg. gen_p + lineor_info._q, # cst, eg. gen_q + lineor_info._v, # cst, eg. gen_v + p_subs, q_subs, + p_bus, q_bus, + v_bus, + ) + cls._aux_kcl_eltype( + cls.n_line, # cst eg. cls.n_gen + cls.line_ex_to_subid, # cst eg. cls.gen_to_subid + lineex_info._bus, # cst eg. self.gen_bus + lineex_info._p, # cst, eg. gen_p + lineex_info._q, # cst, eg. gen_q + lineex_info._v, # cst, eg. gen_v + p_subs, q_subs, + p_bus, q_bus, + v_bus, + ) + cls._aux_kcl_eltype( + cls.n_load, # cst eg. cls.n_gen + cls.load_to_subid, # cst eg. cls.gen_to_subid + load_info._bus, # cst eg. self.gen_bus + load_info._p, # cst, eg. gen_p + load_info._q, # cst, eg. gen_q + load_info._v, # cst, eg. gen_v + p_subs, q_subs, + p_bus, q_bus, + v_bus, + ) + cls._aux_kcl_eltype( + cls.n_gen, # cst eg. cls.n_gen + cls.gen_to_subid, # cst eg. cls.gen_to_subid + gen_info._bus, # cst eg. self.gen_bus + gen_info._p, # cst, eg. gen_p + gen_info._q, # cst, eg. gen_q + gen_info._v, # cst, eg. gen_v + p_subs, q_subs, + p_bus, q_bus, + v_bus, + load_conv=False + ) + if storage_info is not None: + cls._aux_kcl_eltype( + cls.n_storage, # cst eg. cls.n_gen + cls.storage_to_subid, # cst eg. cls.gen_to_subid + storage_info._bus, # cst eg. self.gen_bus + storage_info._p, # cst, eg. gen_p + storage_info._q, # cst, eg. gen_q + storage_info._v, # cst, eg. gen_v + p_subs, q_subs, + p_bus, q_bus, + v_bus, + ) + + if shunt_info is not None: + GridObjects._aux_kcl_eltype( + cls.n_shunt, # cst eg. cls.n_gen + cls.shunt_to_subid, # cst eg. cls.gen_to_subid + shunt_info._bus, # cst eg. self.gen_bus + shunt_info._p, # cst, eg. gen_p + shunt_info._q, # cst, eg. gen_q + shunt_info._v, # cst, eg. gen_v + p_subs, q_subs, + p_bus, q_bus, + v_bus, + ) + else: + warnings.warn( + f"{cls.__name__}.check_kirchhoff Impossible to get shunt information. Reactive information might be " + "incorrect." + ) + diff_v_bus = np.zeros((cls.n_sub, cls.n_busbar_per_sub), dtype=dt_float) + diff_v_bus[:, :] = v_bus[:, :, 1] - v_bus[:, :, 0] + diff_v_bus[np.abs(diff_v_bus - -2. * some_kind_of_inf) <= 1e-5 ] = 0. # disconnected bus + return p_subs, q_subs, p_bus, q_bus, diff_v_bus diff --git a/grid2op/Space/__init__.py b/grid2op/Space/__init__.py index 8a71e1dd..7b3683b9 100644 --- a/grid2op/Space/__init__.py +++ b/grid2op/Space/__init__.py @@ -1,9 +1,15 @@ __all__ = ["RandomObject", "SerializableSpace", "GridObjects", + "ElTypeInfo", "DEFAULT_N_BUSBAR_PER_SUB", - "GRID2OP_CLASSES_ENV_FOLDER"] + "GRID2OP_CLASSES_ENV_FOLDER", + "DEFAULT_ALLOW_DETACHMENT"] from grid2op.Space.RandomObject import RandomObject from grid2op.Space.SerializableSpace import SerializableSpace -from grid2op.Space.GridObjects import GridObjects, DEFAULT_N_BUSBAR_PER_SUB, GRID2OP_CLASSES_ENV_FOLDER +from grid2op.Space.GridObjects import (GridObjects, + ElTypeInfo, + DEFAULT_N_BUSBAR_PER_SUB, + GRID2OP_CLASSES_ENV_FOLDER, + DEFAULT_ALLOW_DETACHMENT) diff --git a/grid2op/Space/space_utils.py b/grid2op/Space/space_utils.py index 0ab3dc95..285bc955 100644 --- a/grid2op/Space/space_utils.py +++ b/grid2op/Space/space_utils.py @@ -7,6 +7,9 @@ # This file is part of Grid2Op, Grid2Op a testbed platform to model sequential decision making in power systems. import copy +from typing import Optional +import numpy as np + from grid2op.Exceptions import Grid2OpException # i already issued the warning for the "some substations have no controllable elements" @@ -74,3 +77,35 @@ def save_to_dict(res_dict, me, key, converter, copy_=True): ) raise Grid2OpException(msg_err_.format(key)) res_dict[key] = res + + +class ElTypeInfo: + """ + For each element type (*eg* generator, or load) this is a container for basic element + information, it is used in the `check_kirchhoff` functions of observation :func:`grid2op.Observation.BaseObservation.check_kirchhoff` + and backend :func:`grid2op.Backend.Backend.check_kirchhoff` + + The element it contains are: + + - el_bus : np.ndarray + for each element of this element type, on which busbar this element is connected (*eg* obs.gen_bus) + - el_p : np.ndarray + for each element of this element type, it gives the active power value consumed / produced by this element (*eg* obs.gen_p) + - el_q : np.ndarray + for each element of this element type, it gives the reactive power value consumed / produced by this element (*eg* obs.gen_q) + - el_v : np.ndarray + for each element of this element type, it gives the voltage magnitude of the bus to which this element (*eg* obs.gen_v) + is connected + + """ + def __init__(self, + el_bus : np.ndarray, + el_p : np.ndarray, + el_q : np.ndarray, + el_v : Optional[np.ndarray] = None, + # load_conv: bool = True + ): + self._bus = el_bus + self._p = el_p + self._q = el_q + self._v = el_v diff --git a/grid2op/__init__.py b/grid2op/__init__.py index 95050dfb..e387ae1c 100644 --- a/grid2op/__init__.py +++ b/grid2op/__init__.py @@ -11,7 +11,7 @@ Grid2Op a testbed platform to model sequential decision making in power systems. """ -__version__ = '1.11.0.dev2' +__version__ = '1.11.0.dev3' __all__ = [ "Action", diff --git a/grid2op/gym_compat/box_gym_obsspace.py b/grid2op/gym_compat/box_gym_obsspace.py index fa88d303..13298b2d 100644 --- a/grid2op/gym_compat/box_gym_obsspace.py +++ b/grid2op/gym_compat/box_gym_obsspace.py @@ -850,7 +850,8 @@ def get_indexes(self, key: str) -> Tuple[int, int]: key = "redispatch" # "redispatch", "curtail", "set_storage" start_, end_ = gym_env.action_space.get_indexes(key) act[start_:end_] = np.random.uniform(high=1, low=-1, size=env.gen_redispatchable.sum()) - # act only modifies the redispatch with the input given (here a uniform redispatching between -1 and 1) + # act only modifies the redispatch with the input given + # (here a uniform redispatching between -1 and 1) """ error_msg =(f"Impossible to use the grid2op action property \"{key}\"" diff --git a/grid2op/gym_compat/gym_act_space.py b/grid2op/gym_compat/gym_act_space.py index c0dd4643..02c7887c 100644 --- a/grid2op/gym_compat/gym_act_space.py +++ b/grid2op/gym_compat/gym_act_space.py @@ -111,6 +111,9 @@ class __AuxGymActionSpace: "shunt_p": "_shunt_p", "shunt_q": "_shunt_q", "shunt_bus": "_shunt_bus", + "_detach_load": "detach_load", # new in 1.11.0 + "_detach_gen": "detach_gen", # new in 1.11.0 + "_detach_storage": "detach_storage", # new in 1.11.0 } keys_human_2_grid2op = {v: k for k, v in keys_grid2op_2_human.items()} @@ -213,7 +216,7 @@ def reencode_space(self, key, fun): raise RuntimeError( "Impossible to reencode a space that is a converter space." ) - + my_dict = self.get_dict_encoding() if fun is not None and not isinstance(fun, type(self)._BaseGymAttrConverterType): raise RuntimeError( diff --git a/grid2op/gym_compat/gym_space_converter.py b/grid2op/gym_compat/gym_space_converter.py index 5aa7d509..d10c75e6 100644 --- a/grid2op/gym_compat/gym_space_converter.py +++ b/grid2op/gym_compat/gym_space_converter.py @@ -213,7 +213,10 @@ def get_dict_encoding(self): ------- """ - return copy.deepcopy(self._keys_encoding) + res = {} + for k, v in self._keys_encoding.items(): + res[k] = v # TODO shedding, why I can't deep copy this anymore ? + return res def reencode_space(self, key, func): """ diff --git a/grid2op/simulator/simulator.py b/grid2op/simulator/simulator.py index 553d82f8..d0e8e5e6 100644 --- a/grid2op/simulator/simulator.py +++ b/grid2op/simulator/simulator.py @@ -570,6 +570,7 @@ def predict( res = self.copy() else: res = self + this_act = act.copy() if new_gen_p is None: diff --git a/grid2op/tests/_aux_test_gym_compat.py b/grid2op/tests/_aux_test_gym_compat.py index 6f574b37..59435553 100644 --- a/grid2op/tests/_aux_test_gym_compat.py +++ b/grid2op/tests/_aux_test_gym_compat.py @@ -153,7 +153,7 @@ def test_convert_togym(self): for el in env_gym.observation_space.spaces ] ) - size_th = 536 # as of grid2Op 1.7.1 (where all obs attributes are there) + size_th = 542 # as of grid2Op 1.11.0 (with obs.gen_p_delta) assert ( dim_obs_space == size_th ), f"Size should be {size_th} but is {dim_obs_space}" @@ -1683,7 +1683,6 @@ def test_supported_keys(self): # check that all types ok_ = func_check[attr_nm](grid2op_act) if not ok_: - pdb.set_trace() raise RuntimeError( f"Some property of the actions are not modified for attr {attr_nm}" ) diff --git a/grid2op/tests/aaa_test_backend_interface.py b/grid2op/tests/aaa_test_backend_interface.py index 6d403c2b..0d0d8ccf 100644 --- a/grid2op/tests/aaa_test_backend_interface.py +++ b/grid2op/tests/aaa_test_backend_interface.py @@ -14,6 +14,7 @@ from grid2op.dtypes import dt_int from grid2op.tests.helper_path_test import HelperTests, MakeBackend, PATH_DATA from grid2op.Exceptions import BackendError, Grid2OpException +from grid2op.Space import DEFAULT_ALLOW_DETACHMENT, DEFAULT_N_BUSBAR_PER_SUB class AAATestBackendAPI(MakeBackend): @@ -39,21 +40,36 @@ def aux_get_env_name(self): """do not run nor modify ! (used for this test class only)""" return "BasicTest_load_grid_" + type(self).__name__ - def aux_make_backend(self, n_busbar=2) -> Backend: + def aux_make_backend(self, + n_busbar=DEFAULT_N_BUSBAR_PER_SUB, + allow_detachment=DEFAULT_ALLOW_DETACHMENT, + extra_name=None) -> Backend: """do not run nor modify ! (used for this test class only)""" - backend = self.make_backend_with_glue_code(n_busbar=n_busbar) + + if extra_name is None: + extra_name = self.aux_get_env_name() + backend = self.make_backend_with_glue_code(n_busbar=n_busbar, + allow_detachment=allow_detachment, + extra_name=extra_name) backend.load_grid(self.get_path(), self.get_casefile()) backend.load_redispacthing_data("tmp") # pretend there is no generator backend.load_storage_data(self.get_path()) - env_name = self.aux_get_env_name() - backend.env_name = env_name - backend.assert_grid_correct() + backend.assert_grid_correct() return backend def test_00create_backend(self): """Tests the backend can be created (not integrated in a grid2op environment yet)""" self.skip_if_needed() backend = self.make_backend_with_glue_code() + if backend._missing_two_busbars_support_info: + warnings.warn("You should call either `self.can_handle_more_than_2_busbar()` " + "or `self.cannot_handle_more_than_2_busbar()` in the `load_grid` " + "method of your backend. Please refer to documentation for more information.") + + if backend._missing_detachment_support_info: + warnings.warn("You should call either `self.can_handle_detachment()` " + "or `self.cannot_handle_detachment()` in the `load_grid` " + "method of your backend. Please refer to documentation for more information.") def test_01load_grid(self): """Tests the grid can be loaded (supposes that your backend can read the grid.json in educ_case14_storage)* @@ -787,8 +803,41 @@ def test_15_reset(self): assert np.allclose(q2_or, q_or), f"The q_or flow differ between its original value and after a reset. Check backend.reset()" assert np.allclose(v2_or, v_or), f"The v_or differ between its original value and after a reset. Check backend.reset()" assert np.allclose(a2_or, a_or), f"The a_or flow differ between its original value and after a reset. Check backend.reset()" - - def test_16_isolated_load_stops_computation(self): + + def _aux_aux_test_detachment_should_fail(self, maybe_exc): + assert maybe_exc is not None, "When your backend diverges, we expect it throws an exception (second return value)" + assert isinstance(maybe_exc, Grid2OpException), f"When your backend return `False`, we expect it throws an exception inheriting from Grid2OpException (second return value), backend returned {type(maybe_exc)}" + if not isinstance(maybe_exc, BackendError): + warnings.warn("The error returned by your backend when it stopped (due to isolated element) should preferably inherit from BackendError") + + def _aux_test_detachment(self, backend : Backend, is_dc=True, detachment_should_pass = False): + """auxilliary method to handle the "legacy" code, when the backend was expected to + handle the error """ + str_ = "DC" if is_dc else "AC" + if backend._missing_detachment_support_info: + # legacy behaviour, should behave as if it diverges + # for new (>= 1.11.0) behaviour, it is catched in the method `_runpf_with_diverging_exception` + res = backend.runpf(is_dc=is_dc) + assert not res[0], f"It is expected (at time of writing) that your backend returns `False` in case of isolated elements (eg load, gen or storage unit) in {str_}." + maybe_exc = res[1] + detachment_allowed = False + else: + # new (1.11.0) test here + maybe_exc = backend._runpf_with_diverging_exception(is_dc=is_dc) + detachment_allowed = type(backend).detachment_is_allowed + + if not detachment_allowed: + # should raise in all cases as the backend prevent detachment + self._aux_aux_test_detachment_should_fail(maybe_exc) + elif not detachment_should_pass: + # it expected that even if the backend supports detachment, + # this test should fail (kwargs detachment_should_pass set to False) + self._aux_aux_test_detachment_should_fail(maybe_exc) + else: + # detachment should not make things diverge + assert maybe_exc is None, f"Your backend supports detachment of loads or generator, yet it diverges when some loads / generators are disconnected." + + def test_16_isolated_load_stops_computation(self, allow_detachment=DEFAULT_ALLOW_DETACHMENT): """Tests that an isolated load will be spotted by the `run_pf` method and forwarded to grid2op by returining `False, an_exception` (in AC and DC) This test supposes that : @@ -802,9 +851,12 @@ def test_16_isolated_load_stops_computation(self): Currently this stops the computation of the environment and lead to a game over. This behaviour might change in the future. + + .. note:: + This test is also used in `attr:AAATestBackendAPI.test_33_allow_detachment` """ self.skip_if_needed() - backend = self.aux_make_backend() + backend = self.aux_make_backend(allow_detachment=allow_detachment) cls = type(backend) # a load alone on a bus @@ -814,13 +866,8 @@ def test_16_isolated_load_stops_computation(self): bk_act += action backend.apply_action(bk_act) # mix of bus 1 and 2 on substation 1 res = backend.runpf(is_dc=False) - assert not res[0], "It is expected (at time of writing) that your backend returns `False` in case of isolated loads in AC." - assert res[1] is not None, "When your backend diverges, we expect it throws an exception (second return value)" - error = res[1] - assert isinstance(error, Grid2OpException), f"When your backend return `False`, we expect it throws an exception inheriting from Grid2OpException (second return value), backend returned {type(error)}" - if not isinstance(error, BackendError): - warnings.warn("The error returned by your backend when it stopped (due to isolated shunt) should preferably inherit from BackendError") - + self._aux_test_detachment(backend, is_dc=False) + backend.reset(self.get_path(), self.get_casefile()) # a load alone on a bus action = type(backend)._complete_action_class() @@ -828,15 +875,9 @@ def test_16_isolated_load_stops_computation(self): bk_act = type(backend).my_bk_act_class() bk_act += action backend.apply_action(bk_act) # mix of bus 1 and 2 on substation 1 - res = backend.runpf(is_dc=True) - assert not res[0], "It is expected (at time of writing) that your backend returns `False` in case of isolated loads in DC." - assert res[1] is not None, "When your backend diverges, we expect it throws an exception (second return value)" - error = res[1] - assert isinstance(error, Grid2OpException), f"When your backend return `False`, we expect it throws an exception inheriting from Grid2OpException (second return value), backend returned {type(error)}" - if not isinstance(error, BackendError): - warnings.warn("The error returned by your backend when it stopped (due to isolated shunt) should preferably inherit from BackendError") - - def test_17_isolated_gen_stops_computation(self): + self._aux_test_detachment(backend, is_dc=True) + + def test_17_isolated_gen_stops_computation(self, allow_detachment=DEFAULT_ALLOW_DETACHMENT): """Tests that an isolated generator will be spotted by the `run_pf` method and forwarded to grid2op by returining `False, an_exception` (in AC and DC) This test supposes that : @@ -850,9 +891,12 @@ def test_17_isolated_gen_stops_computation(self): Currently this stops the computation of the environment and lead to a game over. This behaviour might change in the future. + + .. note:: + This test is also used in `attr:AAATestBackendAPI.test_33_allow_detachment` """ self.skip_if_needed() - backend = self.aux_make_backend() + backend = self.aux_make_backend(allow_detachment=allow_detachment) cls = type(backend) # disconnect a gen @@ -861,14 +905,8 @@ def test_17_isolated_gen_stops_computation(self): bk_act = type(backend).my_bk_act_class() bk_act += action backend.apply_action(bk_act) # mix of bus 1 and 2 on substation 1 - res = backend.runpf(is_dc=False) - assert not res[0], "It is expected (at time of writing) that your backend returns `False` in case of isolated gen." - assert res[1] is not None, "When your backend diverges, we expect it throws an exception (second return value)" - error = res[1] - assert isinstance(error, Grid2OpException), f"When your backend return `False`, we expect it throws an exception inheriting from Grid2OpException (second return value), backend returned {type(error)}" - if not isinstance(error, BackendError): - warnings.warn("The error returned by your backend when it stopped (due to isolated shunt) should preferably inherit from BackendError") - + self._aux_test_detachment(backend, is_dc=False) + backend.reset(self.get_path(), self.get_casefile()) # disconnect a gen action = type(backend)._complete_action_class() @@ -876,15 +914,9 @@ def test_17_isolated_gen_stops_computation(self): bk_act = type(backend).my_bk_act_class() bk_act += action backend.apply_action(bk_act) # mix of bus 1 and 2 on substation 1 - res = backend.runpf(is_dc=True) - assert not res[0], "It is expected (at time of writing) that your backend returns `False` in case of isolated gen." - assert res[1] is not None, "When your backend diverges, we expect it throws an exception (second return value)" - error = res[1] - assert isinstance(error, Grid2OpException), f"When your backend return `False`, we expect it throws an exception inheriting from Grid2OpException (second return value), backend returned {type(error)}" - if not isinstance(error, BackendError): - warnings.warn("The error returned by your backend when it stopped (due to isolated shunt) should preferably inherit from BackendError") - - def test_18_isolated_shunt_stops_computation(self): + self._aux_test_detachment(backend, is_dc=True) + + def test_18_isolated_shunt_stops_computation(self, allow_detachment=DEFAULT_ALLOW_DETACHMENT): """Tests test that an isolated shunt will be spotted by the `run_pf` method and forwarded to grid2op by returining `False, an_exception` (in AC and DC) This test supposes that : @@ -900,9 +932,12 @@ def test_18_isolated_shunt_stops_computation(self): Currently this stops the computation of the environment and lead to a game over. This behaviour might change in the future. + + .. note:: + This test is also used in `attr:AAATestBackendAPI.test_33_allow_detachment` """ self.skip_if_needed() - backend = self.aux_make_backend() + backend = self.aux_make_backend(allow_detachment=allow_detachment) cls = type(backend) if not cls.shunts_data_available: self.skipTest("Your backend does not support shunts") @@ -915,14 +950,8 @@ def test_18_isolated_shunt_stops_computation(self): bk_act = type(backend).my_bk_act_class() bk_act += action backend.apply_action(bk_act) # mix of bus 1 and 2 on substation 1 - res = backend.runpf(is_dc=False) - assert not res[0], "It is expected (at time of writing) that your backend returns `False` in case of isolated shunt." - assert res[1] is not None, "When your backend diverges, we expect it throws an exception (second return value)" - error = res[1] - assert isinstance(error, Grid2OpException), f"When your backend return `False`, we expect it throws an exception inheriting from Grid2OpException (second return value), backend returned {type(error)}" - if not isinstance(error, BackendError): - warnings.warn("The error returned by your backend when it stopped (due to isolated shunt) should preferably inherit from BackendError") - + self._aux_test_detachment(backend, is_dc=False) + backend.reset(self.get_path(), self.get_casefile()) # make a shunt alone on a bus action = type(backend)._complete_action_class() @@ -930,15 +959,9 @@ def test_18_isolated_shunt_stops_computation(self): bk_act = type(backend).my_bk_act_class() bk_act += action backend.apply_action(bk_act) # mix of bus 1 and 2 on substation 1 - res = backend.runpf(is_dc=True) - assert not res[0], "It is expected (at time of writing) that your backend returns `False` in case of isolated shunt in DC." - assert res[1] is not None, "When your backend stops, we expect it throws an exception (second return value)" - error = res[1] - assert isinstance(error, Grid2OpException), f"When your backend returns `False`, we expect it throws an exception inheriting from Grid2OpException (second return value), backend returned {type(error)}" - if not isinstance(error, BackendError): - warnings.warn("The error returned by your backend when it stopped (due to isolated shunt) should preferably inherit from BackendError") - - def test_19_isolated_storage_stops_computation(self): + self._aux_test_detachment(backend, is_dc=True) + + def test_19_isolated_storage_stops_computation(self, allow_detachment=DEFAULT_ALLOW_DETACHMENT): """Teststest that an isolated storage unit will be spotted by the `run_pf` method and forwarded to grid2op by returining `False, an_exception` (in AC and DC) This test supposes that : @@ -953,10 +976,11 @@ def test_19_isolated_storage_stops_computation(self): .. note:: Currently this stops the computation of the environment and lead to a game over. - This behaviour might change in the future. + .. note:: + This test is also used in `attr:AAATestBackendAPI.test_33_allow_detachment` """ self.skip_if_needed() - backend = self.aux_make_backend() + backend = self.aux_make_backend(allow_detachment=allow_detachment) cls = type(backend) if cls.n_storage == 0: self.skipTest("Your backend does not support storage units") @@ -966,30 +990,21 @@ def test_19_isolated_storage_stops_computation(self): bk_act = type(backend).my_bk_act_class() bk_act += action backend.apply_action(bk_act) # mix of bus 1 and 2 on substation 1 - res = backend.runpf(is_dc=False) - assert not res[0], "It is expected (at time of writing) that your backend returns `False` in case of isolated storage units in AC." - assert res[1] is not None, "When your backend stops, we expect it throws an exception (second return value)" - error = res[1] - assert isinstance(error, Grid2OpException), f"When your backend return `False`, we expect it throws an exception inheriting from Grid2OpException (second return value), backend returned {type(error)}" - if not isinstance(error, BackendError): - warnings.warn("The error returned by your backend when it stopped (due to isolated storage units) should preferably inherit from BackendError") - + self._aux_test_detachment(backend, is_dc=False) + backend.reset(self.get_path(), self.get_casefile()) action = type(backend)._complete_action_class() action.update({"set_bus": {"storages_id": [(0, 2)]}}) bk_act = type(backend).my_bk_act_class() bk_act += action backend.apply_action(bk_act) # mix of bus 1 and 2 on substation 1 - res = backend.runpf(is_dc=True) - assert not res[0], "It is expected (at time of writing) that your backend returns `False` in case of isolated storage unit." - assert res[1] is not None, "When your backend stops, we expect it throws an exception (second return value)" - error = res[1] - assert isinstance(error, Grid2OpException), f"When your backend return `False`, we expect it throws an exception inheriting from Grid2OpException (second return value), backend returned {type(error)}" - if not isinstance(error, BackendError): - warnings.warn("The error returned by your backend when it stopped (due to isolated storage units) should preferably inherit from BackendError") - - def test_20_disconnected_load_stops_computation(self): - """Tests that a disconnected load unit will be spotted by the `run_pf` method and forwarded to grid2op by returining `False, an_exception` (in AC and DC) + self._aux_test_detachment(backend, is_dc=True) + + def test_20_disconnected_load_stops_computation(self, allow_detachment=DEFAULT_ALLOW_DETACHMENT): + """ + Tests that a disconnected load unit will be caught by the `_runpf_with_diverging_exception` method + if loads are not allowed to be "detached" from the grid (or if your backend does not support + the "detachment" feature.) This test supposes that : @@ -998,15 +1013,14 @@ def test_20_disconnected_load_stops_computation(self): - backend.apply_action() for topology modification - backend.reset() is implemented - NB: this test is skipped if your backend does not (yet :-) ) supports storage units - .. note:: Currently this stops the computation of the environment and lead to a game over. - This behaviour might change in the future. + .. note:: + This test is also used in `attr:AAATestBackendAPI.test_33_allow_detachment` """ self.skip_if_needed() - backend = self.aux_make_backend() + backend = self.aux_make_backend(allow_detachment=allow_detachment) # a load alone on a bus action = type(backend)._complete_action_class() @@ -1014,13 +1028,7 @@ def test_20_disconnected_load_stops_computation(self): bk_act = type(backend).my_bk_act_class() bk_act += action backend.apply_action(bk_act) # mix of bus 1 and 2 on substation 1 - res = backend.runpf(is_dc=False) - assert not res[0], "It is expected (at time of writing) that your backend returns `False` in case of disconnected load in AC." - assert res[1] is not None, "When your backend stops, we expect it throws an exception (second return value)" - error = res[1] - assert isinstance(error, Grid2OpException), f"When your backend return `False`, we expect it throws an exception inheriting from Grid2OpException (second return value), backend returned {type(error)}" - if not isinstance(error, BackendError): - warnings.warn("The error returned by your backend when it stopped (due to disconnected load) should preferably inherit from BackendError") + self._aux_test_detachment(backend, is_dc=False, detachment_should_pass=True) backend.reset(self.get_path(), self.get_casefile()) # a load alone on a bus @@ -1029,16 +1037,13 @@ def test_20_disconnected_load_stops_computation(self): bk_act = type(backend).my_bk_act_class() bk_act += action backend.apply_action(bk_act) # mix of bus 1 and 2 on substation 1 - res = backend.runpf(is_dc=True) - assert not res[0], "It is expected (at time of writing) that your backend returns `False` in case of disconnected load in DC." - assert res[1] is not None, "When your backend stops, we expect it throws an exception (second return value)" - error = res[1] - assert isinstance(error, Grid2OpException), f"When your backend return `False`, we expect it throws an exception inheriting from Grid2OpException (second return value), backend returned {type(error)}" - if not isinstance(error, BackendError): - warnings.warn("The error returned by your backend when it stopped (due to disconnected load) should preferably inherit from BackendError") - - def test_21_disconnected_gen_stops_computation(self): - """Tests that a disconnected generator will be spotted by the `run_pf` method and forwarded to grid2op by returining `False, an_exception` (in AC and DC) + self._aux_test_detachment(backend, is_dc=True, detachment_should_pass=True) + + def test_21_disconnected_gen_stops_computation(self, allow_detachment=DEFAULT_ALLOW_DETACHMENT): + """ + Tests that a disconnected generator will be caught by the `_runpf_with_diverging_exception` method + if generators are not allowed to be "detached" from the grid (or if your backend does not support + the "detachment" feature.) This test supposes that : @@ -1047,15 +1052,14 @@ def test_21_disconnected_gen_stops_computation(self): - backend.apply_action() for topology modification - backend.reset() is implemented - NB: this test is skipped if your backend does not (yet :-) ) supports storage units - .. note:: Currently this stops the computation of the environment and lead to a game over. - This behaviour might change in the future. + .. note:: + This test is also used in `attr:AAATestBackendAPI.test_33_allow_detachment` """ self.skip_if_needed() - backend = self.aux_make_backend() + backend = self.aux_make_backend(allow_detachment=allow_detachment) # a disconnected generator action = type(backend)._complete_action_class() @@ -1063,13 +1067,7 @@ def test_21_disconnected_gen_stops_computation(self): bk_act = type(backend).my_bk_act_class() bk_act += action backend.apply_action(bk_act) # mix of bus 1 and 2 on substation 1 - res = backend.runpf(is_dc=False) - assert not res[0], "It is expected (at time of writing) that your backend returns `False` in case of disconnected gen in AC." - assert res[1] is not None, "When your backend stops, we expect it throws an exception (second return value)" - error = res[1] - assert isinstance(error, Grid2OpException), f"When your backend return `False`, we expect it throws an exception inheriting from Grid2OpException (second return value), backend returned {type(error)}" - if not isinstance(error, BackendError): - warnings.warn("The error returned by your backend when it stopped (due to disconnected gen) should preferably inherit from BackendError") + self._aux_test_detachment(backend, is_dc=False, detachment_should_pass=True) backend.reset(self.get_path(), self.get_casefile()) # a disconnected generator @@ -1078,14 +1076,8 @@ def test_21_disconnected_gen_stops_computation(self): bk_act = type(backend).my_bk_act_class() bk_act += action backend.apply_action(bk_act) # mix of bus 1 and 2 on substation 1 - res = backend.runpf(is_dc=True) - assert not res[0], "It is expected (at time of writing) that your backend returns `False` in case of disconnected gen in DC." - assert res[1] is not None, "When your backend stops, we expect it throws an exception (second return value)" - error = res[1] - assert isinstance(error, Grid2OpException), f"When your backend return `False`, we expect it throws an exception inheriting from Grid2OpException (second return value), backend returned {type(error)}" - if not isinstance(error, BackendError): - warnings.warn("The error returned by your backend when it stopped (due to disconnected gen) should preferably inherit from BackendError") - + self._aux_test_detachment(backend, is_dc=True, detachment_should_pass=True) + def test_22_islanded_grid_stops_computation(self): """Tests that when the grid is split in two different "sub_grid" is spotted by the `run_pf` method and forwarded to grid2op by returining `False, an_exception` (in AC and DC) @@ -1100,8 +1092,6 @@ def test_22_islanded_grid_stops_computation(self): - backend.apply_action() for topology modification - backend.reset() is implemented - NB: this test is skipped if your backend does not (yet :-) ) supports storage units - .. note:: Currently this stops the computation of the environment and lead to a game over. @@ -1276,7 +1266,7 @@ def test_25_disco_storage_v_null(self): res = backend.runpf(is_dc=True) assert res[0], f"Your backend diverged in DC after a storage disconnection, error was {res[1]}" p_, q_, v_ = backend.storages_info() - assert np.allclose(v_[storage_id], 0.), f"v should be 0. for disconnected storage, but is currently {v_[storage_id]} (AC)" + assert np.allclose(v_[storage_id], 0.), f"v should be 0. for disconnected storage, but is currently {v_[storage_id]} (DC)" def test_26_copy(self): """Tests that the backend can be copied (and that the copied backend and the @@ -1485,7 +1475,7 @@ def _aux_check_el_generic(self, backend, busbar_id, key_: val # move the line }}) bk_act = type(backend).my_bk_act_class() - bk_act += action + bk_act += action # "compile" all the user action into one single action sent to the backend backend.apply_action(bk_act) # apply the action res = backend.runpf(is_dc=False) assert res[0], f"Your backend diverged in AC after setting a {el_nm} on busbar {busbar_id}, error was {res[1]}" @@ -1699,4 +1689,110 @@ def test_30_n_busbar_per_sub_ok(self): el_nm, el_key, el_pos_topo_vect) else: warnings.warn(f"{type(self).__name__} test_30_n_busbar_per_sub_ok: This test is not performed in depth as your backend does not support storage units (or there are none on the grid)") + + def _aux_disco_sto_then_add_sto_p(self, backend: Backend): + action = type(backend)._complete_action_class() + action.update({"set_bus": {"storages_id": [(0, -1)]}}) + bk_act = type(backend).my_bk_act_class() + bk_act += action + backend.apply_action(bk_act) + action = type(backend)._complete_action_class() + action.update({"set_storage": [(0, 0.1)]}) + bk_act = type(backend).my_bk_act_class() + bk_act += action + backend.apply_action(bk_act) + + def test_31_disconnected_storage_with_p_stops_computation(self, allow_detachment=DEFAULT_ALLOW_DETACHMENT): + """ + Tests that a disconnected storage unit that is asked to produce active power + raise an error if the backend does not support `allow_detachment` + + This test supposes that : + + - backend.load_grid(...) is implemented + - backend.runpf() (AC and DC mode) is implemented + - backend.apply_action() for topology modification + - backend.reset() is implemented + + NB: this test is skipped if your backend does not (yet :-) ) supports storage units + + .. note:: + Currently this stops the computation of the environment and lead to a game over. + + .. note:: + This test is also used in `attr:AAATestBackendAPI.test_33_allow_detachment` + + """ + self.skip_if_needed() + backend = self.aux_make_backend(allow_detachment=allow_detachment) + if type(backend).n_storage == 0: + self.skipTest("Your backend does not support storage unit") + + # a disconnected generator + self._aux_disco_sto_then_add_sto_p(backend) + self._aux_test_detachment(backend, is_dc=False, detachment_should_pass=True) + + backend.reset(self.get_path(), self.get_casefile()) + # a disconnected generator + self._aux_disco_sto_then_add_sto_p(backend) + self._aux_test_detachment(backend, is_dc=True, detachment_should_pass=True) + + def test_32_xxx_handle_detachment_called(self): + """Tests that at least one of the function: + + - :func:`grid2op.Backend.Backend.can_handle_detachment` + - :func:`grid2op.Backend.Backend.cannot_handle_detachment` + + has been implemented in the :func:`grid2op.Backend.Backend.load_grid` + implementation. + + This test supposes that : + + - backend.load_grid(...) is implemented + + .. versionadded:: 1.11.0 + + """ + self.skip_if_needed() + backend = self.aux_make_backend() + assert not backend._missing_detachment_support_info + + def test_33_allow_detachment(self): + """Tests that your backend model disconnected load / generator (is the proper flag is present.) + + Concretely it will run the tests + + - :attr:`TestBackendAPI.test_16_isolated_load_stops_computation` + - :attr:`TestBackendAPI.test_17_isolated_gen_stops_computation` + - :attr:`TestBackendAPI.test_18_isolated_shunt_stops_computation` + - :attr:`TestBackendAPI.test_19_isolated_storage_stops_computation` + - :attr:`TestBackendAPI.test_20_disconnected_load_stops_computation` + - :attr:`TestBackendAPI.test_21_disconnected_gen_stops_computation` + + When your backend is initialized with "allow_detachment". + + NB: of course these tests have been modified such that things that should pass + will pass and things that should fail will fail. + + .. versionadded:: 1.11.0 + + """ + self.skip_if_needed() + backend = self.aux_make_backend(allow_detachment=True) + if backend._missing_detachment_support_info: + self.skipTest("Cannot perform this test as you have not specified whether " + "the backend class supports the 'detachement' of loads and " + "generators. Falling back to default grid2op behaviour, which " + "is to fail if a load or a generator is disconnected.") + if not type(backend).detachment_is_allowed: + self.skipTest("Cannot perform this test as your backend does not appear " + "to support the `detachment` information: a disconnect load " + "or generator is necessarily causing a game over.") + self.test_16_isolated_load_stops_computation(allow_detachment=True) + self.test_17_isolated_gen_stops_computation(allow_detachment=True) + self.test_18_isolated_shunt_stops_computation(allow_detachment=True) + self.test_19_isolated_storage_stops_computation(allow_detachment=True) + self.test_20_disconnected_load_stops_computation(allow_detachment=True) + self.test_21_disconnected_gen_stops_computation(allow_detachment=True) + self.test_31_disconnected_storage_with_p_stops_computation(allow_detachment=True) \ No newline at end of file diff --git a/grid2op/tests/automatic_classes.py b/grid2op/tests/automatic_classes.py index c50b91c5..0fca0185 100644 --- a/grid2op/tests/automatic_classes.py +++ b/grid2op/tests/automatic_classes.py @@ -154,7 +154,8 @@ def test_all_classes_from_file(self, f"ObservationSpace_{classes_name}", f"PandaPowerBackend_{classes_name}", name_action_cls, - f"VoltageOnlyAction_{classes_name}" + f"VoltageOnlyAction_{classes_name}", + f"_ForecastEnv_{classes_name}", ] names_attr = ["action_space", "_backend_action_class", @@ -167,6 +168,7 @@ def test_all_classes_from_file(self, "backend", "_actionClass", None, # VoltageOnlyAction not in env + None, # _ForecastEnv_ not in env ] # NB: these imports needs to be consistent with what is done in # base_env.generate_classes() and gridobj.init_grid(...) @@ -531,7 +533,8 @@ def setUp(self) -> None: warnings.filterwarnings("ignore") self.env = grid2op.make("l2rpn_case14_sandbox", test=True, - class_in_file=True) + class_in_file=True, + ) self.line_id = 3 th_lim = self.env.get_thermal_limit() * 2. # avoid all problem in general th_lim[self.line_id] /= 10. # make sure to get trouble in line 3 @@ -611,6 +614,12 @@ def test_asynch_fork(self): obs = async_vect_env.reset() def test_asynch_spawn(self): + # test I can reset everything on the same process + env1 = GymEnv(self.env) + env2 = GymEnv(self.env) + obs1, info1 = env1.reset() + obs2, info2 = env2.reset() + # now do the same in the same process async_vect_env = AsyncVectorEnv((lambda: GymEnv(self.env), lambda: GymEnv(self.env)), context="spawn") obs = async_vect_env.reset() diff --git a/grid2op/tests/helper_path_test.py b/grid2op/tests/helper_path_test.py index cd72b9ef..35083efa 100644 --- a/grid2op/tests/helper_path_test.py +++ b/grid2op/tests/helper_path_test.py @@ -67,12 +67,17 @@ class MakeBackend(ABC, HelperTests): def make_backend(self, detailed_infos_for_cascading_failures=False) -> Backend: pass - def make_backend_with_glue_code(self, detailed_infos_for_cascading_failures=False, extra_name="", n_busbar=2) -> Backend: + def make_backend_with_glue_code(self, + detailed_infos_for_cascading_failures=False, + extra_name="", + n_busbar=2, + allow_detachment=False) -> Backend: Backend._clear_class_attribute() bk = self.make_backend(detailed_infos_for_cascading_failures=detailed_infos_for_cascading_failures) type(bk)._clear_grid_dependant_class_attributes() type(bk).set_env_name(type(self).__name__ + extra_name) type(bk).set_n_busbar_per_sub(n_busbar) + type(bk).set_detachment_is_allowed(allow_detachment) return bk def get_path(self) -> str: diff --git a/grid2op/tests/test_Action.py b/grid2op/tests/test_Action.py index 059686f0..9a98f4e1 100644 --- a/grid2op/tests/test_Action.py +++ b/grid2op/tests/test_Action.py @@ -31,6 +31,7 @@ def _get_action_grid_class(): GridObjects._clear_class_attribute() GridObjects.env_name = "test_action_env" GridObjects.n_busbar_per_sub = 2 + GridObjects.detachment_is_allowed = False GridObjects.n_gen = 5 GridObjects.name_gen = np.array(["gen_{}".format(i) for i in range(5)]) GridObjects.n_load = 11 @@ -107,6 +108,7 @@ def _get_action_grid_class(): json_ = { "glop_version": grid2op.__version__, "n_busbar_per_sub": "2", + "detachment_is_allowed": "False", "name_gen": ["gen_0", "gen_1", "gen_2", "gen_3", "gen_4"], "name_load": [ "load_0", @@ -868,20 +870,24 @@ def test_to_vect(self): "set_bus": {"substations_id": [(id_2, arr2)]}, } ) - - res = action.to_vect() - tmp = np.zeros(self.size_act) - if "curtail" in action.authorized_keys: + act_cls = type(action) + + act_serialized = action.to_vect() + th_res = np.zeros(self.size_act) + if "curtail" in act_cls.authorized_keys: # for curtailment, at the end, and by default its -1 - tmp[-action.n_gen :] = -1 - + th_res[-action.n_gen :] = -1 + # set to nan the first elements + # corresponding to prod_p, prod_v, load_p and load_q + if "injection" in act_cls.authorized_keys: + th_res[:(2 * (act_cls.n_gen + act_cls.n_load))] = np.nan # compute the "set_bus" vect - id_set = np.nonzero(np.array(type(action).attr_list_vect) == "_set_topo_vect")[0][0] + id_set = np.nonzero(np.array(act_cls.attr_list_vect) == "_set_topo_vect")[0][0] size_before = 0 - for el in type(action).attr_list_vect[:id_set]: + for el in act_cls.attr_list_vect[:id_set]: arr_ = action._get_array_from_attr_name(el) size_before += arr_.shape[0] - tmp[size_before : (size_before + action.dim_topo)] = np.array( + th_res[size_before : (size_before + action.dim_topo)] = np.array( [ 0, 0, @@ -943,14 +949,14 @@ def test_to_vect(self): 0, ] ) - id_change = np.nonzero(np.array(type(action).attr_list_vect) == "_change_bus_vect")[0][ + id_change = np.nonzero(np.array(act_cls.attr_list_vect) == "_change_bus_vect")[0][ 0 ] size_before = 0 - for el in type(action).attr_list_vect[:id_change]: + for el in act_cls.attr_list_vect[:id_change]: arr_ = action._get_array_from_attr_name(el) size_before += arr_.shape[0] - tmp[size_before : (size_before + action.dim_topo)] = 1.0 * np.array( + th_res[size_before : (size_before + act_cls.dim_topo)] = 1.0 * np.array( [ False, False, @@ -1012,8 +1018,8 @@ def test_to_vect(self): False, ] ) - assert np.all(res[np.isfinite(tmp)] == tmp[np.isfinite(tmp)]) - assert np.all(np.isfinite(res) == np.isfinite(tmp)) + assert np.all(act_serialized[np.isfinite(th_res)] == th_res[np.isfinite(th_res)]) + assert np.all(np.isfinite(act_serialized) == np.isfinite(th_res)) def test__eq__(self): self._skipMissingKey("set_bus") diff --git a/grid2op/tests/test_Observation.py b/grid2op/tests/test_Observation.py index 0e85a27b..80f66fdf 100644 --- a/grid2op/tests/test_Observation.py +++ b/grid2op/tests/test_Observation.py @@ -18,7 +18,7 @@ import grid2op from grid2op.dtypes import dt_int, dt_float, dt_bool from grid2op.Exceptions import * -from grid2op.Observation import ObservationSpace +from grid2op.Observation import ObservationSpace, CompleteObservation from grid2op.Reward import ( L2RPNReward, CloseToOverflowReward, @@ -53,6 +53,7 @@ def setUp(self): self.dict_ = { "name_gen": ["gen_1_0", "gen_2_1", "gen_5_2", "gen_7_3", "gen_0_4"], "n_busbar_per_sub": "2", + "detachment_is_allowed": "False", "name_load": [ "load_1_0", "load_2_1", @@ -847,6 +848,14 @@ def setUp(self): "time_since_last_attack": [], "was_alert_used_after_attack": [], "attack_under_alert": [], + "gen_p_delta": [0.0, 0.0, 0.0, 0.0, 2.2990264892578125], + # "load_detached": [False, False, False, False, False, False, False, False, False, False, False], + # "gen_detached": [False, False, False, False, False], + # "storage_detached": [], + # "load_p_detached": [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0], + # "load_q_detached": [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0], + # "gen_p_detached": [0.0, 0.0, 0.0, 0.0, 0.0], + # "storage_p_detached": [], } self.dtypes = np.array( [ @@ -915,6 +924,16 @@ def setUp(self): dt_int, dt_int, dt_int, + # slack (>= 1.11.0) + dt_float, + # detachment (>= 1.11.0) + # dt_bool, + # dt_bool, + # dt_bool, + # dt_float, + # dt_float, + # dt_float, + # dt_float, ], dtype=object, ) @@ -982,10 +1001,20 @@ def setUp(self): 0, 0, 0, - 0 + 0, + # slack (>= 1.11.0) + 5, + # # detachment (>= 1.11.0) + # 11, + # 5, + # 0, + # 11, + # 11, + # 5, + # 0, ] ) - self.size_obs = 429 + 4 + 4 + 2 + 1 + 10 + 5 + 0 + self.size_obs = 429 + 4 + 4 + 2 + 1 + 10 + 5 + 0 + 5 # + 11 + 5 + 0 + 11 + 11 + 5 def tearDown(self): self.env.close() @@ -2303,10 +2332,9 @@ def test_json_loadable(self): def test_to_from_json(self): """test the to_json, and from_json and make sure these are all json serializable""" - obs = self.env.observation_space(self.env) - obs2 = self.env.observation_space(self.env) - dict_ = obs.to_json() - + obs : CompleteObservation = self.env.observation_space(self.env) + obs2 : CompleteObservation = self.env.observation_space(self.env) + dict_ = obs.to_json() # test that the right dictionary is returned for k in dict_: assert ( @@ -2327,7 +2355,9 @@ def test_to_from_json(self): # test i can initialize an observation from it obs2.reset() obs2.from_json(dict_realoaded) - assert obs == obs2 + if obs != obs2: + diff_, attr_diff = obs2.where_different(obs) + raise AssertionError(f"Following attributes are different: {attr_diff}") class TestUpdateEnvironement(unittest.TestCase): diff --git a/grid2op/tests/test_PandaPowerBackendDefaultFunc.py b/grid2op/tests/test_PandaPowerBackendDefaultFunc.py index 33a29011..3770e336 100644 --- a/grid2op/tests/test_PandaPowerBackendDefaultFunc.py +++ b/grid2op/tests/test_PandaPowerBackendDefaultFunc.py @@ -64,7 +64,8 @@ def get_topo_vect(self): """ otherwise there are some infinite recursions """ - res = np.full(self.dim_topo, fill_value=-1, dtype=dt_int) + self._topo_vect.flags.writeable = True + res = self._topo_vect line_status = np.concatenate( ( @@ -112,13 +113,14 @@ def get_topo_vect(self): for bus_id in self._grid.gen["bus"].values: res[self.gen_pos_topo_vect[i]] = 1 if bus_id == self.gen_to_subid[i] else 2 i += 1 - + res[self.gen_pos_topo_vect[~self._grid.gen["in_service"]]] = -1 i = 0 for bus_id in self._grid.load["bus"].values: res[self.load_pos_topo_vect[i]] = ( 1 if bus_id == self.load_to_subid[i] else 2 ) i += 1 + res[self.load_pos_topo_vect[~self._grid.load["in_service"]]] = -1 # do not forget storage units ! i = 0 @@ -127,6 +129,7 @@ def get_topo_vect(self): 1 if bus_id == self.storage_to_subid[i] else 2 ) i += 1 + self._topo_vect.flags.writeable = False return res diff --git a/grid2op/tests/test_alert_gym_compat.py b/grid2op/tests/test_alert_gym_compat.py index e522deee..59a1e5fc 100644 --- a/grid2op/tests/test_alert_gym_compat.py +++ b/grid2op/tests/test_alert_gym_compat.py @@ -77,44 +77,43 @@ def test_print_alert(self): "1.4 0. 0. 0. 0. 0. 2.8 0. 0. 2.8\n 0. 0. 4.3 0. 0. 2.8 8.5 9.9], " "(22,), float32), 'set_bus': Box(-1, 2, (177,), int32), 'set_line_status': Box(-1, 1, (59,), int32))") str_ = env_gym.observation_space.__str__() + assert str_ == ("Dict('_shunt_bus': Box(-2147483648, 2147483647, (6,), int32), '_shunt_p': Box(-inf, inf, (6,), float32), " - "'_shunt_q': Box(-inf, inf, (6,), float32), '_shunt_v': Box(-inf, inf, (6,), float32), " - "'a_ex': Box(0.0, inf, (59,), float32), 'a_or': Box(0.0, inf, (59,), float32), 'active_alert': MultiBinary(10), " - "'actual_dispatch': Box([ -50. -67.2 -50. -250. -50. -33.6 -37.3 -37.3 -33.6 -74.7\n -100. -37.3 -37.3 " - "-100. -74.7 -74.7 -150. -67.2 -74.7 -400.\n -300. -350. ], [ 50. 67.2 50. 250. 50. 33.6 37.3 37.3 " - "33.6 74.7 100. 37.3\n 37.3 100. 74.7 74.7 150. 67.2 74.7 400. 300. 350. ], (22,), float32), " - "'alert_duration': Box(0, 2147483647, (10,), int32), 'attack_under_alert': Box(-1, 1, (10,), int32), " - "'attention_budget': Box(0.0, inf, (1,), float32), 'current_step': Box(-2147483648, 2147483647, (1,), int32), " - "'curtailment': Box(0.0, 1.0, (22,), float32), 'curtailment_limit': Box(0.0, 1.0, (22,), float32), " - "'curtailment_limit_effective': Box(0.0, 1.0, (22,), float32), 'day': Discrete(32), 'day_of_week': Discrete(8), " - "'delta_time': Box(0.0, inf, (1,), float32), 'duration_next_maintenance': Box(-1, 2147483647, (59,), int32), " - "'gen_margin_down': Box(0.0, [ 1.4 0. 1.4 10.4 1.4 0. 0. 0. 0. 0. 2.8 0. 0. 2.8\n 0. 0. " - "4.3 0. 0. 2.8 8.5 9.9], (22,), float32), 'gen_margin_up': Box(0.0, [ 1.4 0. 1.4 10.4 1.4 0. " - "0. 0. 0. 0. 2.8 0. 0. 2.8\n 0. 0. 4.3 0. 0. 2.8 8.5 9.9], (22,), float32), " - "'gen_p': Box(-734.88995, [ 784.88995 802.08997 784.88995 984.88995 784.88995 768.4899\n " - "772.18994 772.18994 768.4899 809.58997 834.88995 772.18994\n 772.18994 834.88995 " - "809.58997 809.58997 884.88995 802.08997\n 809.58997 1134.8899 1034.8899 1084.8899 ], " - "(22,), float32), 'gen_p_before_curtail': Box(-734.88995, [ 784.88995 802.08997 784.88995 " - "984.88995 784.88995 768.4899\n 772.18994 772.18994 768.4899 809.58997 834.88995 " - "772.18994\n 772.18994 834.88995 809.58997 809.58997 884.88995 802.08997\n 809.58997 " - "1134.8899 1034.8899 1084.8899 ], (22,), float32), 'gen_q': Box(-inf, inf, (22,), float32), " - "'gen_theta': Box(-180.0, 180.0, (22,), float32), 'gen_v': Box(0.0, inf, (22,), float32), " - "'hour_of_day': Discrete(24), 'is_alarm_illegal': Discrete(2), 'line_status': MultiBinary(59), " - "'load_p': Box(-inf, inf, (37,), float32), 'load_q': Box(-inf, inf, (37,), float32), " - "'load_theta': Box(-180.0, 180.0, (37,), float32), 'load_v': Box(0.0, inf, (37,), float32), " - "'max_step': Box(-2147483648, 2147483647, (1,), int32), 'minute_of_hour': Discrete(60), " - "'month': Discrete(13), 'p_ex': Box(-inf, inf, (59,), float32), 'p_or': Box(-inf, inf, (59,), float32), " - "'q_ex': Box(-inf, inf, (59,), float32), 'q_or': Box(-inf, inf, (59,), float32), 'rho': Box(0.0, inf, (59,), float32), " - "'target_dispatch': Box([ -50. -67.2 -50. -250. -50. -33.6 -37.3 -37.3 -33.6 -74.7\n -100. " - "-37.3 -37.3 -100. -74.7 -74.7 -150. -67.2 -74.7 -400.\n -300. -350. ], [ 50. 67.2 50. 250. 50. " - "33.6 37.3 37.3 33.6 74.7 100. 37.3\n 37.3 100. 74.7 74.7 150. 67.2 74.7 400. 300. 350. ], " - "(22,), float32), 'thermal_limit': Box(0.0, inf, (59,), float32), 'theta_ex': Box(-180.0, 180.0, (59,), " - "float32), 'theta_or': Box(-180.0, 180.0, (59,), float32), 'time_before_cooldown_line': Box(0, 96, (59,), int32), " - "'time_before_cooldown_sub': Box(0, 3, (36,), int32), 'time_next_maintenance': Box(-1, 2147483647, (59,), int32), " - "'time_since_last_alarm': Box(-1, 2147483647, (1,), int32), 'time_since_last_alert': Box(-1, 2147483647, (10,), int32), " - "'time_since_last_attack': Box(-1, 2147483647, (10,), int32), 'timestep_overflow': Box(-2147483648, 2147483647, (59,), int32), " - "'topo_vect': Box(-1, 2, (177,), int32), 'total_number_of_alert': Box(0, 2147483647, (1,), int32), 'v_ex': Box(0.0, inf, " - "(59,), float32), 'v_or': Box(0.0, inf, (59,), float32), 'was_alarm_used_after_game_over': Discrete(2), " + "'_shunt_q': Box(-inf, inf, (6,), float32), '_shunt_v': Box(-inf, inf, (6,), float32), 'a_ex': Box(0.0, inf, " + "(59,), float32), 'a_or': Box(0.0, inf, (59,), float32), 'active_alert': MultiBinary(10), 'actual_dispatch': " + "Box([ -50. -67.2 -50. -250. -50. -33.6 -37.3 -37.3 -33.6 -74.7\n -100. -37.3 -37.3 -100. -74.7 " + "-74.7 -150. -67.2 -74.7 -400.\n -300. -350. ], [ 50. 67.2 50. 250. 50. 33.6 37.3 37.3 33.6 74.7 100. " + "37.3\n 37.3 100. 74.7 74.7 150. 67.2 74.7 400. 300. 350. ], (22,), float32), 'alert_duration': " + "Box(0, 2147483647, (10,), int32), 'attack_under_alert': Box(-1, 1, (10,), int32), 'attention_budget': " + "Box(0.0, inf, (1,), float32), 'current_step': Box(-2147483648, 2147483647, (1,), int32), 'curtailment': " + "Box(0.0, 1.0, (22,), float32), 'curtailment_limit': Box(0.0, 1.0, (22,), float32), 'curtailment_limit_effective': " + "Box(0.0, 1.0, (22,), float32), 'day': Discrete(32), 'day_of_week': Discrete(8), 'delta_time': Box(0.0, inf, (1,), " + "float32), 'duration_next_maintenance': Box(-1, 2147483647, (59,), int32), 'gen_margin_down': Box(0.0, " + "[ 1.4 0. 1.4 10.4 1.4 0. 0. 0. 0. 0. 2.8 0. 0. 2.8\n 0. 0. 4.3 0. 0. 2.8 8.5 " + "9.9], (22,), float32), 'gen_margin_up': Box(0.0, [ 1.4 0. 1.4 10.4 1.4 0. 0. 0. 0. 0. 2.8 0. " + "0. 2.8\n 0. 0. 4.3 0. 0. 2.8 8.5 9.9], (22,), float32), 'gen_p': Box(-734.88995, [ 784.88995 " + "802.08997 784.88995 984.88995 784.88995 768.4899\n 772.18994 772.18994 768.4899 809.58997 834.88995 " + "772.18994\n 772.18994 834.88995 809.58997 809.58997 884.88995 802.08997\n 809.58997 1134.8899 1034.8899 " + "1084.8899 ], (22,), float32), 'gen_p_before_curtail': Box(-734.88995, [ 784.88995 802.08997 784.88995 984.88995 " + "784.88995 768.4899\n 772.18994 772.18994 768.4899 809.58997 834.88995 772.18994\n 772.18994 834.88995 " + "809.58997 809.58997 884.88995 802.08997\n 809.58997 1134.8899 1034.8899 1084.8899 ], (22,), float32), " + "'gen_p_delta': Box(-inf, inf, (22,), float32), 'gen_q': Box(-inf, inf, (22,), float32), 'gen_theta': Box(-180.0, " + "180.0, (22,), float32), 'gen_v': Box(0.0, inf, (22,), float32), 'hour_of_day': Discrete(24), 'is_alarm_illegal': " + "Discrete(2), 'line_status': MultiBinary(59), 'load_p': Box(-inf, inf, (37,), float32), 'load_q': Box(-inf, inf, " + "(37,), float32), 'load_theta': Box(-180.0, 180.0, (37,), float32), 'load_v': Box(0.0, inf, (37,), float32), 'max_step': " + "Box(-2147483648, 2147483647, (1,), int32), 'minute_of_hour': Discrete(60), 'month': Discrete(13), 'p_ex': " + "Box(-inf, inf, (59,), float32), 'p_or': Box(-inf, inf, (59,), float32), 'q_ex': Box(-inf, inf, (59,), float32), " + "'q_or': Box(-inf, inf, (59,), float32), 'rho': Box(0.0, inf, (59,), float32), 'target_dispatch': Box([ -50. " + "-67.2 -50. -250. -50. -33.6 -37.3 -37.3 -33.6 -74.7\n -100. -37.3 -37.3 -100. -74.7 -74.7 " + "-150. -67.2 -74.7 -400.\n -300. -350. ], [ 50. 67.2 50. 250. 50. 33.6 37.3 37.3 33.6 74.7 100. " + "37.3\n 37.3 100. 74.7 74.7 150. 67.2 74.7 400. 300. 350. ], (22,), float32), 'thermal_limit': Box(0.0, inf, " + "(59,), float32), 'theta_ex': Box(-180.0, 180.0, (59,), float32), 'theta_or': Box(-180.0, 180.0, (59,), float32), " + "'time_before_cooldown_line': Box(0, 96, (59,), int32), 'time_before_cooldown_sub': Box(0, 3, (36,), int32), " + "'time_next_maintenance': Box(-1, 2147483647, (59,), int32), 'time_since_last_alarm': Box(-1, 2147483647, " + "(1,), int32), 'time_since_last_alert': Box(-1, 2147483647, (10,), int32), 'time_since_last_attack': " + "Box(-1, 2147483647, (10,), int32), 'timestep_overflow': Box(-2147483648, 2147483647, (59,), int32), " + "'topo_vect': Box(-1, 2, (177,), int32), 'total_number_of_alert': Box(0, 2147483647, (1,), int32), 'v_ex': " + "Box(0.0, inf, (59,), float32), 'v_or': Box(0.0, inf, (59,), float32), 'was_alarm_used_after_game_over': Discrete(2), " "'was_alert_used_after_attack': Box(-1, 1, (10,), int32), 'year': Discrete(2100))") act = self.env.action_space() act.raise_alert = [2] @@ -168,6 +167,7 @@ def test_convert_alert_to_gym(self): ] ) size_th = 1718 # as of grid2Op 1.9.1 (where alerts are added) + size_th = 1740 # as of grid2Op 1.11.0 (where gen_p_delta) assert ( dim_obs_space == size_th ), f"Size should be {size_th} but is {dim_obs_space}" diff --git a/grid2op/tests/test_attached_envs.py b/grid2op/tests/test_attached_envs.py index 0451dfb5..7a09e9c5 100644 --- a/grid2op/tests/test_attached_envs.py +++ b/grid2op/tests/test_attached_envs.py @@ -182,7 +182,52 @@ def test_action_space(self): def test_observation_space(self): assert issubclass(self.env.observation_space.subtype, CompleteObservation) - size_th = 467 + # size_th = 467 + size_th = 473 # gen_delta + assert self.env.observation_space.n == size_th, ( + f"obs space size is {self.env.observation_space.n}," f"should be {size_th}" + ) + + def test_random_action(self): + """test i can perform some step (random)""" + i = 0 + for i in range(10): + act = self.env.action_space.sample() + obs, reward, done, info = self.env.step(act) + if done: + break + assert i >= 1, ( + "could not perform the random action test because it games over first time step. " + "Please fix the test and try again" + ) + + +class TestL2RPN_CASE14_SANDBOX_DETACH(unittest.TestCase): + def setUp(self) -> None: + with warnings.catch_warnings(): + warnings.filterwarnings("ignore") + self.env = grid2op.make("l2rpn_case14_sandbox", test=True, _add_to_name=type(self).__name__, allow_detachment=True) + self.env.seed(42) + _ = self.env.reset() + + def test_elements(self): + assert type(self.env).n_sub == 14 + assert type(self.env).n_line == 20 + assert type(self.env).n_load == 11 + assert type(self.env).n_gen == 6 + assert type(self.env).n_storage == 0 + + def test_opponent(self): + assert issubclass(self.env._opponent_action_class, DontAct) + assert self.env._opponent_action_space.n == 0 + + def test_action_space(self): + assert issubclass(self.env.action_space.subtype, PlayableAction) + assert self.env.action_space.n == 166, f"{self.env.action_space.n} instead of 166" + + def test_observation_space(self): + assert issubclass(self.env.observation_space.subtype, CompleteObservation) + size_th = 518 assert self.env.observation_space.n == size_th, ( f"obs space size is {self.env.observation_space.n}," f"should be {size_th}" ) @@ -226,7 +271,8 @@ def test_action_space(self): def test_observation_space(self): assert issubclass(self.env.observation_space.subtype, CompleteObservation) - size_th = 467 + # size_th = 467 + size_th = 473 # gen_p_delta assert self.env.observation_space.n == size_th, ( f"obs space size is {self.env.observation_space.n}," f"should be {size_th}" ) @@ -270,7 +316,8 @@ def test_action_space(self): def test_observation_space(self): assert issubclass(self.env.observation_space.subtype, CompleteObservation) - size_th = 475 + # size_th = 475 + size_th = 481 # gen_p_delta assert self.env.observation_space.n == size_th, ( f"obs space size is {self.env.observation_space.n}," f"should be {size_th}" ) diff --git a/grid2op/tests/test_attached_envs_compat.py b/grid2op/tests/test_attached_envs_compat.py index 9b790497..7a40d86e 100644 --- a/grid2op/tests/test_attached_envs_compat.py +++ b/grid2op/tests/test_attached_envs_compat.py @@ -12,6 +12,7 @@ import grid2op import numpy as np +from grid2op.Backend import Backend, PandaPowerBackend from grid2op.Space import GridObjects from grid2op.Action import PowerlineSetAction, DontAct, PlayableAction from grid2op.Observation import CompleteObservation @@ -46,11 +47,11 @@ def test_opponent(self): def test_action_space(self): assert issubclass(self.env.action_space.subtype, PlayableAction) - assert self.env.action_space.n == 494 + assert self.env.action_space.n == 494, f"{self.env.action_space.n}" def test_observation_space(self): assert issubclass(self.env.observation_space.subtype, CompleteObservation) - assert self.env.observation_space.n == 1266 + assert self.env.observation_space.n == 1266, f"{self.env.observation_space.n}" def test_random_action(self): """test i can perform some step (random)""" @@ -91,14 +92,14 @@ def test_opponent(self): def test_action_space(self): assert issubclass(self.env.action_space.subtype, PlayableAction) - assert self.env.action_space.n == 1500 + assert self.env.action_space.n == 1500, f"{self.env.action_space.n}" def test_observation_space(self): assert issubclass(self.env.observation_space.subtype, CompleteObservation) assert ( "curtailment" not in self.env.observation_space.subtype.attr_list_vect ), "curtailment should not be there" - assert self.env.observation_space.n == 3868 + assert self.env.observation_space.n == 3868, f"{self.env.observation_space.n}" def test_random_action(self): """test i can perform some step (random)""" @@ -139,11 +140,11 @@ def test_opponent(self): def test_action_space(self): assert issubclass(self.env.action_space.subtype, PlayableAction) - assert self.env.action_space.n == 160 + assert self.env.action_space.n == 160, f"{self.env.action_space.n}" def test_observation_space(self): assert issubclass(self.env.observation_space.subtype, CompleteObservation) - assert self.env.observation_space.n == 420 + assert self.env.observation_space.n == 420, f"{self.env.observation_space.n}" def test_random_action(self): """test i can perform some step (random)""" @@ -184,11 +185,11 @@ def test_opponent(self): def test_action_space(self): assert issubclass(self.env.action_space.subtype, PlayableAction) - assert self.env.action_space.n == 26 + assert self.env.action_space.n == 26, f"{self.env.action_space.n}" def test_observation_space(self): assert issubclass(self.env.observation_space.subtype, CompleteObservation) - assert self.env.observation_space.n == 420 + assert self.env.observation_space.n == 420, f"{self.env.observation_space.n}" def test_random_action(self): """test i can perform some step (random)""" @@ -225,15 +226,15 @@ def test_elements(self): def test_opponent(self): assert issubclass(self.env._opponent_action_class, DontAct) - assert self.env._opponent_action_space.n == 0 + assert self.env._opponent_action_space.n == 0, f"{self.env._opponent_action_space.n}" def test_action_space(self): assert issubclass(self.env.action_space.subtype, PlayableAction) - assert self.env.action_space.n == 26 + assert self.env.action_space.n == 26, f"{self.env.action_space.n}" def test_observation_space(self): assert issubclass(self.env.observation_space.subtype, CompleteObservation) - assert self.env.observation_space.n == 420 + assert self.env.observation_space.n == 420, f"{self.env.observation_space.n}" def test_same_env_as_no_storage(self): res = 0 diff --git a/grid2op/tests/test_issue_418.py b/grid2op/tests/test_issue_418.py index 0621a2ca..e72d40fa 100644 --- a/grid2op/tests/test_issue_418.py +++ b/grid2op/tests/test_issue_418.py @@ -37,10 +37,8 @@ def test_seed(self): obs = gymenv.reset(seed=42) curt = np.array([1,1.,0.18852758,0.5537014,0.43770432,1]) curt = np.array([-1,-1.,0.18852758,0.5537014,0.43770432,-1]) - year = 571 + year = 1476 # gen_p_delta being sampled before day = 9 - # year = 1887 - # day = 9 # test that the seeding worked also in action space and observation space sampled_act = gymenv.action_space.sample() diff --git a/grid2op/tests/test_n_busbar_per_sub.py b/grid2op/tests/test_n_busbar_per_sub.py index 8a5f7f17..1a64055f 100644 --- a/grid2op/tests/test_n_busbar_per_sub.py +++ b/grid2op/tests/test_n_busbar_per_sub.py @@ -1254,7 +1254,10 @@ def test_move_line_or(self): assert self.env.backend._grid.bus.loc[global_bus]["in_service"] else: assert not self.env.backend._grid.line.iloc[line_id]["in_service"] - self.env.backend.line_status[:] = self.env.backend._get_line_status() # otherwise it's not updated + tmp = self.env.backend._get_line_status() # otherwise it's not updated + self.env.backend.line_status.flags.writeable = True + self.env.backend.line_status[:] = tmp + self.env.backend.line_status.flags.writeable = False topo_vect = self.env.backend._get_topo_vect() assert topo_vect[cls.line_or_pos_topo_vect[line_id]] == new_bus, f"{topo_vect[cls.line_or_pos_topo_vect[line_id]]} vs {new_bus}" @@ -1272,7 +1275,10 @@ def test_move_line_ex(self): assert self.env.backend._grid.bus.loc[global_bus]["in_service"] else: assert not self.env.backend._grid.line.iloc[line_id]["in_service"] - self.env.backend.line_status[:] = self.env.backend._get_line_status() # otherwise it's not updated + tmp = self.env.backend._get_line_status() # otherwise it's not updated + self.env.backend.line_status.flags.writeable = True + self.env.backend.line_status[:] = tmp + self.env.backend.line_status.flags.writeable = False topo_vect = self.env.backend._get_topo_vect() assert topo_vect[cls.line_ex_pos_topo_vect[line_id]] == new_bus, f"{topo_vect[cls.line_ex_pos_topo_vect[line_id]]} vs {new_bus}" diff --git a/grid2op/tests/test_score_wcci_2022.py b/grid2op/tests/test_score_wcci_2022.py index 24bd9fc2..6d43e2fe 100644 --- a/grid2op/tests/test_score_wcci_2022.py +++ b/grid2op/tests/test_score_wcci_2022.py @@ -11,6 +11,7 @@ import numpy as np import grid2op +from grid2op.Action import BaseAction from grid2op.Agent import (BaseAgent, DoNothingAgent) from grid2op.Reward import L2RPNWCCI2022ScoreFun from grid2op.utils import ScoreL2RPN2022 @@ -32,7 +33,6 @@ def setUp(self) -> None: self.scen_id = 0 self.nb_scenario = 2 self.max_iter = 13 - with warnings.catch_warnings(): warnings.filterwarnings("ignore") self.env = grid2op.make("educ_case14_storage", diff --git a/grid2op/tests/test_shedding.py b/grid2op/tests/test_shedding.py new file mode 100644 index 00000000..deddcc1e --- /dev/null +++ b/grid2op/tests/test_shedding.py @@ -0,0 +1,456 @@ +# Copyright (c) 2024, RTE (https://www.rte-france.com) +# See AUTHORS.txt +# This Source Code Form is subject to the terms of the Mozilla Public License, version 2.0. +# If a copy of the Mozilla Public License, version 2.0 was not distributed with this file, +# you can obtain one at http://mozilla.org/MPL/2.0/. +# SPDX-License-Identifier: MPL-2.0 +# This file is part of Grid2Op, Grid2Op a testbed platform to model sequential decision making in power systems. + +import json +import warnings +import unittest +import numpy as np +import tempfile + +import grid2op +from grid2op.dtypes import dt_float +from grid2op.Action.baseAction import BaseAction +from grid2op.Exceptions import AmbiguousAction +from grid2op.Action import CompleteAction +from grid2op.Parameters import Parameters +from grid2op.Action._backendAction import _BackendAction + + +class TestShedding(unittest.TestCase): + + def setUp(self) -> None: + super().setUp() + p = Parameters() + p.MAX_SUB_CHANGED = 5 + with warnings.catch_warnings(): + warnings.filterwarnings("ignore") + self.env = grid2op.make("rte_case5_example", + param=p, + action_class=CompleteAction, + allow_detachment=True, + test=True, + _add_to_name=type(self).__name__) + obs = self.env.reset(seed=0, options={"time serie id": "00"}) # Reproducibility + self.load_lookup = {name:i for i,name in enumerate(self.env.name_load)} + self.gen_lookup = {name:i for i,name in enumerate(self.env.name_gen)} + + def tearDown(self) -> None: + self.env.close() + + def test_shedding_parameter_is_true(self): + assert self.env._allow_detachment is True + assert type(self.env).detachment_is_allowed + assert type(self.env.backend).detachment_is_allowed + assert self.env.backend.detachment_is_allowed + + def test_shed_single_load(self): + # Check that a single load can be shed + load_idx = self.load_lookup["load_4_2"] + load_pos = self.env.load_pos_topo_vect[load_idx] + act = self.env.action_space({ + "set_bus": [(load_pos, -1)] + }) + obs, _, done, info = self.env.step(act) + assert not done + assert info["is_illegal"] is False + assert obs.topo_vect[load_pos] == -1 + + def test_shed_single_generator(self): + # Check that a single generator can be shed + gen_idx = self.gen_lookup["gen_0_0"] + gen_pos = self.env.gen_pos_topo_vect[gen_idx] + act = self.env.action_space({ + "set_bus": [(gen_pos, -1)] + }) + obs, _, done, info = self.env.step(act) + assert not done + assert info["is_illegal"] is False + assert obs.topo_vect[gen_pos] == -1 + + def test_shed_multiple_loads(self): + # Check that multiple loads can be shed at the same time + load_idx1 = self.load_lookup["load_4_2"] + load_idx2 = self.load_lookup["load_3_1"] + load_pos1 = self.env.load_pos_topo_vect[load_idx1] + load_pos2 = self.env.load_pos_topo_vect[load_idx2] + act = self.env.action_space({ + "set_bus": [(load_pos1, -1), (load_pos2, -1)] + }) + obs, _, done, info = self.env.step(act) + assert not done + assert info["is_illegal"] is False + assert obs.topo_vect[load_pos1] == -1 + assert obs.topo_vect[load_pos2] == -1 + + def test_shed_load_and_generator(self): + # Check that load and generator can be shed at the same time + # Check that multiple loads can be shed at the same time + load_idx = self.load_lookup["load_4_2"] + gen_idx = self.gen_lookup["gen_0_0"] + load_pos = self.env.load_pos_topo_vect[load_idx] + gen_pos = self.env.gen_pos_topo_vect[gen_idx] + act = self.env.action_space({ + "set_bus": [(load_pos, -1), (gen_pos, -1)] + }) + obs, _, done, info = self.env.step(act) + assert not done + assert info["is_illegal"] is False + assert obs.topo_vect[load_pos] == -1 + assert obs.topo_vect[gen_pos] == -1 + + def test_shedding_persistance(self): + # Check that components remains disconnected if shed + load_idx = self.load_lookup["load_4_2"] + load_pos = self.env.load_pos_topo_vect[load_idx] + act = self.env.action_space({ + "set_bus": [(load_pos, -1)] + }) + _ = self.env.step(act) + obs, _, done, _ = self.env.step(self.env.action_space({})) + assert not done + assert obs.topo_vect[load_pos] == -1 + + +class TestSheddingActions(unittest.TestCase): + def setUp(self) -> None: + super().setUp() + p = Parameters() + p.MAX_SUB_CHANGED = 999999 + with warnings.catch_warnings(): + warnings.filterwarnings("ignore") + self.env = grid2op.make("educ_case14_storage", + param=p, + action_class=CompleteAction, + allow_detachment=True, + test=True, + _add_to_name=type(self).__name__) + obs = self.env.reset(seed=0, options={"time serie id": 0}) # Reproducibility + assert type(self.env).detachment_is_allowed + assert type(obs).detachment_is_allowed + assert type(self.env.action_space()).detachment_is_allowed + + def tearDown(self) -> None: + self.env.close() + super().tearDown() + + def aux_test_action_property_xxx(self, el_type): + detach_xxx = f"detach_{el_type}" + _detach_xxx = f"_detach_{el_type}" + _modif_detach_xxx = f"_modif_detach_{el_type}" + n_xxx = getattr(type(self.env), f"n_{el_type}") + name_xxx = getattr(type(self.env), f"name_{el_type}") + xxx_change_bus = f"{el_type}_change_bus" + xxx_set_bus = f"{el_type}_set_bus" + xxx_to_subid = getattr(type(self.env),f"{el_type}_to_subid") + + act1 = self.env.action_space() + assert detach_xxx in type(act1).authorized_keys, f"{detach_xxx} not in {type(act1).authorized_keys}" + setattr(act1, detach_xxx, np.ones(n_xxx, dtype=bool)) + assert getattr(act1, _detach_xxx).all() + assert getattr(act1, _modif_detach_xxx) + lines_imp, subs_imp = act1.get_topological_impact(_read_from_cache=False) + assert subs_imp[xxx_to_subid].all() + assert (~lines_imp).all() + + act2 = self.env.action_space() + setattr(act2, detach_xxx, 1) + assert getattr(act2, _detach_xxx)[1] + assert getattr(act2, _modif_detach_xxx) + lines_imp, subs_imp = act2.get_topological_impact(_read_from_cache=False) + assert subs_imp[xxx_to_subid[1]].all() + assert (~lines_imp).all() + + act3 = self.env.action_space() + setattr(act3, detach_xxx, [0, 1]) + assert getattr(act3, _detach_xxx)[0] + assert getattr(act3, _detach_xxx)[1] + assert getattr(act3, _modif_detach_xxx) + lines_imp, subs_imp = act3.get_topological_impact(_read_from_cache=False) + assert subs_imp[xxx_to_subid[[0, 1]]].all() + assert (~lines_imp).all() + + for el_id, el_nm in enumerate(name_xxx): + act4 = self.env.action_space() + setattr(act4, detach_xxx, {el_nm}) + assert getattr(act4, _detach_xxx)[el_id] + assert getattr(act4, _modif_detach_xxx) + lines_imp, subs_imp = act4.get_topological_impact(_read_from_cache=False) + assert subs_imp[xxx_to_subid[el_id]].all() + assert (~lines_imp).all() + + # change and disconnect + act5 = self.env.action_space() + setattr(act5, xxx_change_bus, [0]) + setattr(act5, detach_xxx, [0]) + is_amb, exc_ = act5.is_ambiguous() + assert is_amb, f"error for {el_type}" + assert isinstance(exc_, AmbiguousAction), f"error for {el_type}" + + # set_bus and disconnect + act6 = self.env.action_space() + setattr(act6, xxx_set_bus, [(0, 1)]) + setattr(act6, detach_xxx, [0]) + is_amb, exc_ = act6.is_ambiguous() + assert is_amb, f"error for {el_type}" + assert isinstance(exc_, AmbiguousAction), f"error for {el_type}" + + # flag not set + act7 = self.env.action_space() + getattr(act7, _detach_xxx)[0] = True + is_amb, exc_ = act7.is_ambiguous() + assert is_amb, f"error for {el_type}" + assert isinstance(exc_, AmbiguousAction), f"error for {el_type}" + + for el_id in range(n_xxx): + # test to / from dict + act8 = self.env.action_space() + setattr(act8, detach_xxx, [el_id]) + dict_ = act8.as_serializable_dict() # you can save this dict with the json library + act8_reloaded = self.env.action_space(dict_) + assert act8 == act8_reloaded, f"error for {el_type} for id {el_id}" + + # test to / from json + act9 = self.env.action_space() + setattr(act9, detach_xxx, [el_id]) + dict_ = act9.to_json() + with tempfile.NamedTemporaryFile() as f_tmp: + with open(f_tmp.name, "w", encoding="utf-8") as f: + json.dump(obj=dict_, fp=f) + + with open(f_tmp.name, "r", encoding="utf-8") as f: + dict_reload = json.load(fp=f) + act9_reloaded = self.env.action_space() + act9_reloaded.from_json(dict_reload) + assert act9 == act9_reloaded, f"error for {el_type} for id {el_id}" + + # test to / from vect + act10 = self.env.action_space() + setattr(act10, detach_xxx, [el_id]) + vect_ = act10.to_vect() + act10_reloaded = self.env.action_space() + act10_reloaded.from_vect(vect_) + assert act10 == act10_reloaded, f"error for {el_type} for id {el_id}" + + def test_action_property_load(self): + self.aux_test_action_property_xxx("load") + + def test_action_property_gen(self): + self.aux_test_action_property_xxx("gen") + + def test_action_property_storage(self): + self.aux_test_action_property_xxx("storage") + + def test_backend_action(self): + for load_id in range(self.env.n_load): + bk_act :_BackendAction = self.env.backend.my_bk_act_class() + act = self.env.action_space() + act.detach_load = load_id + assert act._detach_load[load_id] + bk_act += act + ( + active_bus, + (prod_p, prod_v, load_p, load_q, storage), + topo__, + shunts__, + ) = bk_act() + assert topo__.changed[self.env.load_pos_topo_vect[load_id]], f"error for load {load_id}" + assert topo__.values[self.env.load_pos_topo_vect[load_id]] == -1, f"error for load {load_id}" + assert bk_act.get_load_detached()[load_id], f"error for load {load_id}" + assert bk_act.get_load_detached().sum() == 1, f"error for load {load_id}" + + for gen_id in range(self.env.n_gen): + bk_act :_BackendAction = self.env.backend.my_bk_act_class() + act = self.env.action_space() + act.detach_gen = gen_id + assert act._detach_gen[gen_id] + bk_act += act + ( + active_bus, + (prod_p, prod_v, load_p, load_q, storage), + topo__, + shunts__, + ) = bk_act() + assert topo__.changed[self.env.gen_pos_topo_vect[gen_id]], f"error for gen {gen_id}" + assert topo__.values[self.env.gen_pos_topo_vect[gen_id]] == -1, f"error for gen {gen_id}" + assert bk_act.get_gen_detached()[gen_id], f"error for gen {gen_id}" + assert bk_act.get_gen_detached().sum() == 1, f"error for gen {gen_id}" + + for sto_id in range(self.env.n_storage): + bk_act :_BackendAction = self.env.backend.my_bk_act_class() + act = self.env.action_space() + act.detach_storage = sto_id + assert act._detach_storage[sto_id] + bk_act += act + ( + active_bus, + (prod_p, prod_v, load_p, load_q, storage), + topo__, + shunts__, + ) = bk_act() + assert topo__.changed[self.env.storage_pos_topo_vect[sto_id]], f"error for storage {sto_id}" + assert topo__.values[self.env.storage_pos_topo_vect[sto_id]] == -1, f"error for storage {sto_id}" + assert bk_act.get_sto_detached()[sto_id], f"error for storage {sto_id}" + assert bk_act.get_sto_detached().sum() == 1, f"error for storage {sto_id}" + + +class TestSheddingEnv(unittest.TestCase): + def get_parameters(self): + params = Parameters() + params.MAX_SUB_CHANGED = 999999 + return params + + def setUp(self): + params = self.get_parameters() + with warnings.catch_warnings(): + warnings.filterwarnings("ignore") + self.env = grid2op.make("educ_case14_storage", + param=params, + action_class=CompleteAction, + allow_detachment=True, + test=True, + _add_to_name=type(self).__name__) + obs = self.env.reset(seed=0, options={"time serie id": 0}) # Reproducibility + assert type(self.env).detachment_is_allowed + assert type(obs).detachment_is_allowed + assert type(self.env.action_space()).detachment_is_allowed + + def tearDown(self): + self.env.close() + return super().tearDown() + + def test_no_shedding(self): + obs, reward, done, info = self.env.step(self.env.action_space()) + assert (np.abs(self.env._prev_load_p - obs.load_p) <= 1e-7).all() + assert (np.abs(self.env._prev_load_q - obs.load_q) <= 1e-7).all() + assert (np.abs(self.env._prev_gen_p - obs.gen_p) <= 1e-7).all() + # for env + assert (~self.env._loads_detached).all() + assert (~self.env._gens_detached).all() + assert (~self.env._storages_detached).all() + assert (np.abs(self.env._load_p_detached) <= 1e-7).all() + assert (np.abs(self.env._load_q_detached) <= 1e-7).all() + assert (np.abs(self.env._gen_p_detached) <= 1e-7).all() + assert (np.abs(self.env._storage_p_detached) <= 1e-7).all() + # for obs + assert (~obs.load_detached).all() + assert (~obs.gen_detached).all() + assert (~obs.storage_detached).all() + assert (np.abs(obs.load_p_detached) <= 1e-7).all() + assert (np.abs(obs.load_q_detached) <= 1e-7).all() + assert (np.abs(obs.gen_p_detached) <= 1e-7).all() + assert (np.abs(obs.storage_p_detached) <= 1e-7).all() + + # slack ok + assert np.abs(self.env._delta_gen_p.sum() / self.env._gen_activeprod_t.sum()) <= 0.02 # less than 2% losses + + def test_shedding_load_step(self): + # NB warning this test does not pass if STOP_EP_IF_SLACK_BREAK_CONSTRAINTS (slack breaks its rampdown !) + obs, reward, done, info = self.env.step(self.env.action_space({"detach_load": 0})) + # env converged + assert not done + # load properly disconnected + assert obs.topo_vect[obs.load_pos_topo_vect[0]] == -1 + # 0 in the observation for this load + assert np.abs(obs.load_p[0]) <= 1e-7 + assert np.abs(obs.load_q[0]) <= 1e-7 + + # all other loads ok + assert (np.abs(self.env._prev_load_p[1:] - obs.load_p[1:]) <= 1e-7).all() + assert (np.abs(self.env._prev_load_q[1:] - obs.load_q[1:]) <= 1e-7).all() + assert (~self.env._loads_detached[1:]).all() + assert (np.abs(self.env._load_p_detached[1:]) <= 1e-7).all() + assert (np.abs(self.env._load_q_detached[1:]) <= 1e-7).all() + assert (~obs.load_detached[1:]).all() + assert (np.abs(obs.load_p_detached[1:]) <= 1e-7).all() + assert (np.abs(obs.load_q_detached[1:]) <= 1e-7).all() + + # load properly written as detached + normal_load_p = dt_float(21.9) + normal_load_q = dt_float(15.3) + assert np.abs(self.env._load_p_detached[0] - normal_load_p) <= 1e-7 + assert np.abs(self.env._load_q_detached[0] - normal_load_q) <= 1e-7 + assert np.abs(obs.load_p_detached[0] - normal_load_p) <= 1e-7 + assert np.abs(obs.load_q_detached[0] - normal_load_q) <= 1e-7 + + # rest is ok + assert (np.abs(self.env._prev_gen_p - obs.gen_p) <= 1e-7).all() + assert (~self.env._gens_detached).all() + assert (~self.env._storages_detached).all() + assert (np.abs(self.env._gen_p_detached) <= 1e-7).all() + assert (np.abs(self.env._storage_p_detached) <= 1e-7).all() + + assert (~obs.gen_detached).all() + assert (~obs.storage_detached).all() + assert (np.abs(obs.gen_p_detached) <= 1e-7).all() + assert (np.abs(obs.storage_p_detached) <= 1e-7).all() + + # slack completely "messed up" + assert self.env._delta_gen_p.sum() <= -normal_load_p + assert obs.gen_p_delta.sum() <= -normal_load_p + + # another step + obs, reward, done, info = self.env.step(self.env.action_space()) + # env converged + assert not done + # load properly disconnected + assert obs.topo_vect[obs.load_pos_topo_vect[0]] == -1 + # load properly written as detached + normal_load_p = dt_float(22.0) + normal_load_q = dt_float(15.2) + assert np.abs(self.env._load_p_detached[0] - normal_load_p) <= 1e-7 + assert np.abs(self.env._load_q_detached[0] - normal_load_q) <= 1e-7 + assert self.env._delta_gen_p.sum() <= -normal_load_p + + # another step + obs, reward, done, info = self.env.step(self.env.action_space()) + # env converged + assert not done + # load properly disconnected + assert obs.topo_vect[obs.load_pos_topo_vect[0]] == -1 + # load properly written as detached + normal_load_p = dt_float(21.6) + normal_load_q = dt_float(15.1) + assert np.abs(self.env._load_p_detached[0] - normal_load_p) <= 1e-7 + assert np.abs(self.env._load_q_detached[0] - normal_load_q) <= 1e-7 + assert self.env._delta_gen_p.sum() <= -normal_load_p + + # now attached it again + obs, reward, done, info = self.env.step(self.env.action_space({"set_bus": {"loads_id": [(0, 1)]}})) + # env converged + assert not done + # load properly disconnected + assert obs.topo_vect[obs.load_pos_topo_vect[0]] == 1 + # load properly written as detached + assert np.abs(self.env._load_p_detached[0] - 0.) <= 1e-7 + assert np.abs(self.env._load_q_detached[0] - 0.) <= 1e-7 + # slack ok + assert np.abs(self.env._delta_gen_p.sum() / self.env._gen_activeprod_t.sum()) <= 0.02 # less than 2% losses + + + + + +# TODO with the env parameters STOP_EP_IF_SLACK_BREAK_CONSTRAINTS and ENV_DOES_REDISPATCHING +# TODO when something is "re attached" on the grid +# TODO check gen detached does not participate in redisp + +# TODO shedding in simulate +# TODO shedding in Simulator ! + +# TODO Shedding: test when backend does not support it is not set +# TODO shedding: test when user deactivates it it is not set + +# TODO Shedding: Runner + +# TODO Shedding: environment copied +# TODO Shedding: MultiMix environment +# TODO Shedding: TimedOutEnvironment +# TODO Shedding: MaskedEnvironment + +if __name__ == "__main__": + unittest.main() diff --git a/grid2op/typing_variables.py b/grid2op/typing_variables.py index 9ac1ef7c..9a75b8c2 100644 --- a/grid2op/typing_variables.py +++ b/grid2op/typing_variables.py @@ -40,7 +40,8 @@ "injection", "hazards", "maintenance", - "shunt"], + "shunt", + "detach_load"], Any] # TODO improve that (especially the Any part)