diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 5a773177d..3590b7c6c 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -107,6 +107,8 @@ 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. - [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,6 +125,7 @@ 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] logos now have the correct URL - [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(...)` @@ -131,6 +134,8 @@ 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] 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 to be consistent with other way to set the actions (*eg* set_bus) - [IMPROVED] error messages when creating an action which changes the injections @@ -148,13 +153,15 @@ 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 - + [1.10.4] - 2024-10-15 ------------------------- - [FIXED] new pypi link (no change in code) diff --git a/getting_started/13_DetachmentOfLoadsAndGenerators.ipynb b/getting_started/13_DetachmentOfLoadsAndGenerators.ipynb new file mode 100644 index 000000000..81e6a5d84 --- /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/baseAction.py b/grid2op/Action/baseAction.py index 587ee00cc..81a6ac497 100644 --- a/grid2op/Action/baseAction.py +++ b/grid2op/Action/baseAction.py @@ -493,7 +493,7 @@ def __init__(self, _names_chronics_to_backend: Optional[Dict[Literal["loads", "p self._modif_alert = 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) @@ -506,7 +506,7 @@ def process_shunt_satic_data(cls): except ValueError: pass cls.attr_list_set = set(cls.attr_list_vect) - return super().process_shunt_satic_data() + return super().process_shunt_static_data() def copy(self) -> "BaseAction": # sometimes this method is used... @@ -573,8 +573,8 @@ def __copy__(self) -> "BaseAction": return res @classmethod - def process_shunt_satic_data(cls): - return super().process_shunt_satic_data() + def process_shunt_static_data(cls): + return super().process_shunt_static_data() def __deepcopy__(self, memodict={}) -> "BaseAction": res = type(self)() @@ -846,7 +846,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) @@ -1099,7 +1099,7 @@ def __eq__(self, other) -> bool: self._change_bus_vect == other._change_bus_vect ): return False - + # shunts are the same if type(self).shunts_data_available: if self.n_shunt != other.n_shunt: @@ -2163,6 +2163,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 @@ -2374,23 +2402,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 diff --git a/grid2op/Agent/topologyGreedy.py b/grid2op/Agent/topologyGreedy.py index a6f842392..a37492bae 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 15539fed9..62c8cc476 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 @@ -121,9 +120,12 @@ class Backend(GridObjects, ABC): _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 610620575..b073c16cf 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/Chronics/gridStateFromFile.py b/grid2op/Chronics/gridStateFromFile.py index 4874a51a4..009c9d5ca 100644 --- a/grid2op/Chronics/gridStateFromFile.py +++ b/grid2op/Chronics/gridStateFromFile.py @@ -272,17 +272,15 @@ def _assert_correct_second_stage(self, pandas_name, dict_convert, key, extra="") def _init_date_time(self): if os.path.exists(os.path.join(self.path, "start_datetime.info")): with open(os.path.join(self.path, "start_datetime.info"), "r") as f: - a = f.read().rstrip().lstrip() + str_ = f.read().rstrip().lstrip() try: - tmp = datetime.strptime(a, "%Y-%m-%d %H:%M") - except ValueError: - tmp = datetime.strptime(a, "%Y-%m-%d") - except Exception: + tmp = self._datetime_from_str(str_) + except Exception as exc_: raise ChronicsNotFoundError( 'Impossible to understand the content of "start_datetime.info". Make sure ' 'it\'s composed of only one line with a datetime in the "%Y-%m-%d %H:%M"' "format." - ) + ) from exc_ self.start_datetime = tmp self.current_datetime = tmp @@ -880,7 +878,7 @@ def load_next(self): hazard_duration = 1 * self.hazard_duration[self.current_index, :] else: hazard_duration = np.full(self.n_line, fill_value=-1, dtype=dt_int) - + self.current_datetime += self.time_interval self.curr_iter += 1 return ( diff --git a/grid2op/Chronics/gridValue.py b/grid2op/Chronics/gridValue.py index ec47b4502..bb36f605a 100644 --- a/grid2op/Chronics/gridValue.py +++ b/grid2op/Chronics/gridValue.py @@ -15,7 +15,7 @@ import grid2op from grid2op.dtypes import dt_int from grid2op.Space import RandomObject -from grid2op.Exceptions import EnvError, Grid2OpException +from grid2op.Exceptions import EnvError, Grid2OpException, ChronicsNotFoundError # TODO sous echantillonner ou sur echantilloner les scenario: need to modify everything that affect the number # TODO of time steps there, for example "Space.gen_min_time_on" or "params.NB_TIMESTEP_POWERFLOW_ALLOWED" for @@ -90,7 +90,12 @@ class GridValue(RandomObject, ABC): """ NAN_BUT_IN_INT = -9999999 - + ERROR_FORMAT_DATETIME = ChronicsNotFoundError( + 'Impossible to understand the content of the provided "datetime". Make sure ' + 'it can be transformed to a python datetime.datetime object with format' + '"%Y-%m-%d %H:%M"' + ) + def __init__( self, time_interval=timedelta(minutes=5), @@ -474,6 +479,73 @@ def get_hazard_duration_1d(hazard): res[prev_:] = 0 return res + def _datetime_from_str(self, str_: str): + try: + res = datetime.strptime(str_, "%Y-%m-%d %H:%M") + except ValueError: + try: + res = datetime.strptime(str_, "%Y-%m-%d") + except Exception as exc_: + raise type(self).ERROR_FORMAT_DATETIME from exc_ + except Exception as exc_: + raise type(self).ERROR_FORMAT_DATETIME from exc_ + return res + + def set_current_datetime(self, str_: Union[str, datetime]): + """INTERNAL + + ..versionadded: 1.11.0 + + This function adds the possibility to change the current datetime of the current time series (`chronics`) + used. + + It makes things "as if" as many steps were performed since the beginning of the episode as the current number + of steps. + + This means that it impacts: + + - :attr:`GridValue.start_datetime` + - :attr:`GridValue.current_datetime` + + Please provide either a python native "datetime.datetime" object or a datetime encoded as + a string in the format `"%Y-%m-%d %H:%M"` + + Examples + -------- + + You should not use this function directly, but rather by a call to `init datetime` in the + `options` information of the environment, for example with: + + .. code-block:: python + + import grid2op + env_name = "l2rpn_case14_sandbox" + + env = grid2op.make(env_name) + obs = env.reset(options={"init datetime": "2024-12-06 00:00"}) + + Parameters + ---------- + str_ : Union[str, datetime] + _description_ + + Raises + ------ + Grid2OpException + _description_ + """ + if isinstance(str_, str): + dt_ = self._datetime_from_str(str_) + elif isinstance(str_, datetime): + dt_ = str_ + else: + raise Grid2OpException("Impossible to set the current date and time with your input. " + "You have provided a string or a datetime.datetime object but provided " + f"{type(str_)}.") + diff_ = dt_ - self.current_datetime + self.current_datetime = dt_ - self.time_interval + self.start_datetime += diff_ + @abstractmethod def load_next(self): """ diff --git a/grid2op/Chronics/multiFolder.py b/grid2op/Chronics/multiFolder.py index 9e71b8da8..a8dc3f6a5 100644 --- a/grid2op/Chronics/multiFolder.py +++ b/grid2op/Chronics/multiFolder.py @@ -443,6 +443,8 @@ def initialize( order_backend_subs, names_chronics_to_backend=names_chronics_to_backend, ) + self.start_datetime = self.data.start_datetime + self.current_datetime = self.data.current_datetime if self.action_space is not None: self.data.action_space = self.action_space self._max_iter = self.data.max_iter @@ -795,3 +797,8 @@ def cleanup_action_space(self): if self.data is None: return self.data.cleanup_action_space() + + def set_current_datetime(self, str_: Union[str, datetime]): + super().set_current_datetime(str_) # set the attribute of self + self.data.set_current_datetime(str_) # set the attribute of the data + \ No newline at end of file diff --git a/grid2op/Converter/BackendConverter.py b/grid2op/Converter/BackendConverter.py index 4c023c85e..19e62c2af 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 ab4d7056e..0f468e0b0 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 4048cedb1..c8b8ac797 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 042e73520..5ad81b834 100644 --- a/grid2op/Environment/baseEnv.py +++ b/grid2op/Environment/baseEnv.py @@ -28,7 +28,12 @@ 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, @@ -45,6 +50,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 @@ -306,14 +312,16 @@ def foo(manager): "init ts", "max step", "thermal limit", + "init datetime", } 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 @@ -338,7 +346,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, @@ -365,6 +374,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: @@ -661,6 +672,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) @@ -3848,7 +3860,7 @@ def attach_layout(self, grid_layout): if self._opponent_action_space is not None: self._opponent_action_space.attach_layout(res) - def fast_forward_chronics(self, nb_timestep): + def fast_forward_chronics(self, nb_timestep, init_dt=None): """ This method allows you to skip some time step at the beginning of the chronics. @@ -3940,7 +3952,8 @@ def fast_forward_chronics(self, nb_timestep): self._times_before_topology_actionable[:] = np.maximum( ff_time_topo_act, min_time_topo ) - + if init_dt is not None: + self.chronics_handler.set_current_datetime(init_dt) # Update to the fast forward state using a do nothing action self.step(self._action_space({})) diff --git a/grid2op/Environment/environment.py b/grid2op/Environment/environment.py index ce4ccd90d..e0e6c202a 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,7 +265,7 @@ 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)._clear_grid_dependant_class_attributes() @@ -278,8 +281,10 @@ 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).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 +295,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 +427,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 @@ -923,7 +928,7 @@ def __str__(self): def reset_grid(self, init_act_opt : Optional[BaseAction]=None, - method:Literal["combine", "ignore"]="combine"): + method : Literal["combine", "ignore"]="combine"): """ INTERNAL @@ -1043,10 +1048,17 @@ def reset(self, be used, experiments might not be reproducible) options: dict - Some options to "customize" the reset call. For example specifying the "time serie id" (grid2op >= 1.9.8) to use - or the "initial state of the grid" (grid2op >= 1.10.2) or to - start the episode at some specific time in the time series (grid2op >= 1.10.3) with the - "init ts" key. + Some options to "customize" the reset call. For example (see detailed example bellow) : + + - "time serie id" (grid2op >= 1.9.8) to use a given time serie from the input data + - "init state" that allows you to apply a given "action" when generating the + initial observation (grid2op >= 1.10.2) + - "init ts" (grid2op >= 1.10.3) to specify to which "steps" of the time series + the episode will start + - "max step" (grid2op >= 1.10.3) : maximum number of steps allowed for the episode + - "thermal limit" (grid2op >= 1.11.0): which thermal limit to use for this episode + (and the next ones, until they are changed) + - "init datetime": which time stamp is used in the first observation of the episode. See examples for more information about this. Ignored if not set. @@ -1269,6 +1281,28 @@ def reset(self, that `set_max_iter` is permenanent: it impacts all the future episodes and not only the next one. + If you want your environment to start at a given time stamp you can do: + + .. code-block:: python + + import grid2op + env_name = "l2rpn_case14_sandbox" + + env = grid2op.make(env_name) + obs = env.reset(options={"init datetime": "2024-12-06 00:00"}) + obs.year == 2024 + obs.month == 12 + obs.day == 6 + + .. seealso:: + If you specify "init datetime" then the observation resulting to the + `env.reset` call will have this datetime. If you specify also `"skip ts"` + option the behaviour does not change: the first observation will + have the date time attributes you specified. + + In other words, the "init datetime" refers to the initial observation of the + episode and NOT the initial time present in the time series. + """ # process the "options" kwargs # (if there is an init state then I need to process it to remove the @@ -1312,6 +1346,7 @@ def reset(self, else: # reset previous max iter to value set with `env.set_max_iter(...)` (or -1 by default) self.chronics_handler._set_max_iter(self._max_iter) + self.chronics_handler.next_chronics() self.chronics_handler.initialize( self.backend.name_load, @@ -1329,6 +1364,8 @@ def reset(self, self._reset_redispatching() self._reset_vectors_and_timings() # it need to be done BEFORE to prevent cascading failure when there has been + if options is not None and "init datetime" in options: + self.chronics_handler.set_current_datetime(options["init datetime"]) self.reset_grid(init_state, method) if self.viewer_fig is not None: del self.viewer_fig @@ -1336,16 +1373,24 @@ def reset(self, if skip_ts is not None: self._reset_vectors_and_timings() - + if options is None: + init_dt = None + elif "init datetime" in options: + init_dt = options["init datetime"] + else: + init_dt = None + if skip_ts < 1: raise Grid2OpException(f"In `env.reset` the kwargs `init ts` should be an int >= 1, found {options['init ts']}") if skip_ts == 1: self._init_obs = None + if init_dt is not None: + self.chronics_handler.set_current_datetime(init_dt) self.step(self.action_space()) elif skip_ts == 2: - self.fast_forward_chronics(1) + self.fast_forward_chronics(1, init_dt) else: - self.fast_forward_chronics(skip_ts) + self.fast_forward_chronics(skip_ts, init_dt) # if True, then it will not disconnect lines above their thermal limits self._reset_vectors_and_timings() # and it needs to be done AFTER to have proper timings at tbe beginning @@ -1500,6 +1545,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: @@ -2152,6 +2198,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 @@ -2213,7 +2260,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, @@ -2246,6 +2294,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 12bf06114..81c694762 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 af1403506..b1664d64a 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 a1952f99a..377d55c68 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/MakeEnv/Make.py b/grid2op/MakeEnv/Make.py index 89154b38f..63fe19f9d 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_detachmentnt: ``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 39abd725e..6692edfaa 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 1f9849aab..28b1f257a 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 @@ -1054,7 +1054,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 +1067,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): @@ -4852,58 +4852,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 +4880,55 @@ 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 diff --git a/grid2op/Observation/observationSpace.py b/grid2op/Observation/observationSpace.py index 5b4a00d95..1bc0290ad 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/Runner/runner.py b/grid2op/Runner/runner.py index a16a66b63..9ee0e28c6 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 c69f1291a..e29048c66 100644 --- a/grid2op/Space/GridObjects.py +++ b/grid2op/Space/GridObjects.py @@ -29,12 +29,14 @@ 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 @@ -513,6 +515,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 +644,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: @@ -685,6 +692,7 @@ def _clear_class_attribute(cls) -> None: """ cls.shunts_data_available = False cls.n_busbar_per_sub = DEFAULT_N_BUSBAR_PER_SUB + cls.detachment_is_allowed = DEFAULT_ALLOW_DETACHMENT # for redispatching / unit commitment cls._li_attr_disp = [ @@ -2031,17 +2039,9 @@ 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.") - # 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}") @@ -2331,6 +2331,8 @@ def assert_grid_correct_cls(cls): # alert data cls._check_validity_alert_data() + + assert isinstance(cls.detachment_is_allowed, bool) @classmethod def _check_validity_alarm_data(cls): @@ -2968,6 +2970,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: @@ -3013,7 +3018,7 @@ def init_grid(cls, gridobj, force=False, extra_name=None, force_module=None, _lo 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._check_convert_to_np_array() # convert everything to numpy array if force_module is not None: @@ -3085,6 +3090,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 < version.parse("1.11.0.dev0"): + # 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 +3747,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 +4073,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 +4114,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 +4128,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 +4203,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 +4388,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 @@ -4408,7 +4436,7 @@ 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 @@ -4540,7 +4568,7 @@ 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() # add the class in the "globals" for reuse later globals()[name_res] = res_cls @@ -4956,7 +4984,8 @@ class {cls.__name__}({cls._INIT_GRID_CLS.__name__}): sub_info = {sub_info_str} dim_topo = {cls.dim_topo} - + detachment_is_allowed = {cls.detachment_is_allowed} + # to which substation is connected each element load_to_subid = {load_to_subid_str} gen_to_subid = {gen_to_subid_str} @@ -5043,6 +5072,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 +5261,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 8a71e1dd6..7b3683b96 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 0ab3dc952..285bc955d 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/tests/aaa_test_backend_interface.py b/grid2op/tests/aaa_test_backend_interface.py index 6d403c2b2..29ccd59ab 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 not 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 not 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,40 @@ 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 loads 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 +850,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 +865,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 +874,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 +890,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 +904,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 +913,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 +931,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 +949,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 +958,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 +975,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 +989,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 +1012,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 +1027,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 +1036,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 +1051,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 +1066,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 +1075,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 +1091,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 +1265,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 +1474,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 +1688,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 c50b91c51..f0eb5d05a 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(...) @@ -611,6 +613,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 cd72b9efe..35083efab 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 059686f08..a852b3ed8 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._allow_detachment = 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", diff --git a/grid2op/tests/test_Observation.py b/grid2op/tests/test_Observation.py index 0e85a27b3..ea961ef25 100644 --- a/grid2op/tests/test_Observation.py +++ b/grid2op/tests/test_Observation.py @@ -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", diff --git a/grid2op/tests/test_PandaPowerBackendDefaultFunc.py b/grid2op/tests/test_PandaPowerBackendDefaultFunc.py index 33a290119..3770e336f 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 e522deee5..04895e370 100644 --- a/grid2op/tests/test_alert_gym_compat.py +++ b/grid2op/tests/test_alert_gym_compat.py @@ -120,6 +120,7 @@ def test_print_alert(self): act.raise_alert = [2] act_gym = env_gym.action_space.to_gym(act) act_str = act_gym.__str__() + assert act_str == ("OrderedDict([('change_bus', array([False, False, False, False, False, False, False, False, False," "\n False, False, False, False, False, False, False, False, False,\n " "False, False, False, False, False, False, False, False, False,\n False, False, False, " diff --git a/grid2op/tests/test_attached_envs_compat.py b/grid2op/tests/test_attached_envs_compat.py index 9b7904970..7a40d86ec 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_n_busbar_per_sub.py b/grid2op/tests/test_n_busbar_per_sub.py index 8a5f7f170..1a64055f4 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_new_reset.py b/grid2op/tests/test_new_reset.py index a96eac4f5..7baf19205 100644 --- a/grid2op/tests/test_new_reset.py +++ b/grid2op/tests/test_new_reset.py @@ -6,12 +6,14 @@ # 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 datetime import grid2op import unittest import warnings import numpy as np from grid2op.Exceptions import EnvError from grid2op.gym_compat import GymEnv +from grid2op.Exceptions import Grid2OpException class TestNewReset(unittest.TestCase): @@ -79,4 +81,51 @@ def test_gym_env(self): # self._aux_obs_equals(obs_seed, obs) self._aux_obs_equals(obs_ts, obs_seed) self._aux_obs_equals(obs_both, obs_seed) + + def test_init_datetime(self): + # test from str + ref_datetime = datetime.datetime(year=2024, month=12, day=6, hour=0, minute=0) + obs_ts = self.env.reset(options={"init datetime": "2024-12-06 00:00"}) + assert obs_ts.year == 2024, f"{obs_ts.year} vs 2024" + assert obs_ts.month == 12, f"{obs_ts.month} vs 12" + assert obs_ts.day == 6, f"{obs_ts.day} vs 6" + assert obs_ts.hour_of_day == 0, f"{obs_ts.hour_of_day} vs 0" + assert obs_ts.minute_of_hour == 0, f"{obs_ts.minute_of_hour} vs 0" + assert self.env.chronics_handler.start_datetime == ref_datetime + assert self.env.chronics_handler.real_data.data.start_datetime == ref_datetime + + # test from datetime + obs_ts = self.env.reset(options={"init datetime": ref_datetime}) + assert obs_ts.year == 2024, f"{obs_ts.year} vs 2024" + assert obs_ts.month == 12, f"{obs_ts.month} vs 12" + assert obs_ts.day == 6, f"{obs_ts.day} vs 6" + assert obs_ts.hour_of_day == 0, f"{obs_ts.hour_of_day} vs 0" + assert obs_ts.minute_of_hour == 0, f"{obs_ts.minute_of_hour} vs 0" + assert self.env.chronics_handler.start_datetime == ref_datetime + assert self.env.chronics_handler.real_data.data.start_datetime == ref_datetime + + # test an error is raised if format not understood + with self.assertRaises(Grid2OpException): + obs_ts = self.env.reset(options={"init datetime": 1}) + with self.assertRaises(Grid2OpException): + obs_ts = self.env.reset(options={"init datetime": "06-12-2024 00:00"}) + + # test when also skip ts is used + obs_ts = self.env.reset(options={"init datetime": ref_datetime, "init ts": 12}) + assert obs_ts.year == 2024, f"{obs_ts.year} vs 2024" + assert obs_ts.month == 12, f"{obs_ts.month} vs 12" + assert obs_ts.day == 6, f"{obs_ts.day} vs 6" + assert obs_ts.hour_of_day == 0, f"{obs_ts.hour_of_day} vs 0" + assert obs_ts.minute_of_hour == 0, f"{obs_ts.minute_of_hour} vs 0" + this_ref_next = ref_datetime - datetime.timedelta(hours=1) + self.env.chronics_handler.time_interval + assert self.env.chronics_handler.real_data.data.start_datetime == this_ref_next + + # special case when skipping 1 step + obs_ts = self.env.reset(options={"init datetime": ref_datetime, "init ts": 1}) + assert obs_ts.year == 2024, f"{obs_ts.year} vs 2024" + assert obs_ts.month == 12, f"{obs_ts.month} vs 12" + assert obs_ts.day == 6, f"{obs_ts.day} vs 6" + assert obs_ts.hour_of_day == 0, f"{obs_ts.hour_of_day} vs 0" + assert obs_ts.minute_of_hour == 0, f"{obs_ts.minute_of_hour} vs 0" + assert self.env.chronics_handler.real_data.data.start_datetime == ref_datetime \ No newline at end of file diff --git a/grid2op/tests/test_shedding.py b/grid2op/tests/test_shedding.py new file mode 100644 index 000000000..b59eef0e4 --- /dev/null +++ b/grid2op/tests/test_shedding.py @@ -0,0 +1,95 @@ +import warnings +import unittest +import grid2op +from grid2op.Parameters import Parameters + +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, + allow_detachment=True, test=True) + self.env.set_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 self.env.backend.detachment_is_allowed is True + + 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 + +if __name__ == "__main__": + unittest.main() diff --git a/grid2op/typing_variables.py b/grid2op/typing_variables.py index ea19ec211..9ac1ef7c2 100644 --- a/grid2op/typing_variables.py +++ b/grid2op/typing_variables.py @@ -7,8 +7,10 @@ # This file is part of Grid2Op, Grid2Op a testbed platform to model sequential decision making in power systems. from typing import Dict, Literal, Any, Union, List +import datetime import numpy as np + #: type hints corresponding to the "info" part of the env.step return value STEP_INFO_TYPING = Dict[Literal["disc_lines", "is_illegal", @@ -48,6 +50,7 @@ Dict[Literal["init ts"], int], Dict[Literal["max step"], int], Dict[Literal["thermal limit"], Union[List[float], Dict[str, float]]], + Dict[Literal["init datetime"], Union[str, datetime.datetime]], None] #: type hints for a "GridObject" when converted to a dictionary diff --git a/logo/final_logo.png b/logo/final_logo.png index 644966a1c..8426ccf58 100644 Binary files a/logo/final_logo.png and b/logo/final_logo.png differ diff --git a/logo/final_logo_blue.png b/logo/final_logo_blue.png index 22eccd6b2..d0f784e49 100644 Binary files a/logo/final_logo_blue.png and b/logo/final_logo_blue.png differ diff --git a/logo/final_logo_green.png b/logo/final_logo_green.png index fd141aefa..606329650 100644 Binary files a/logo/final_logo_green.png and b/logo/final_logo_green.png differ