From 96e1ca5bcd82399a856a0095a028e89c652fde31 Mon Sep 17 00:00:00 2001 From: Xavier Weiss Date: Mon, 4 Nov 2024 11:45:34 +0100 Subject: [PATCH 01/39] Add: AllowShedding attribute to GridObjects Signed-off-by: Xavier Weiss --- getting_started/13_Shedding.ipynb | 122 +++++++++++++++++++++++ grid2op/Action/baseAction.py | 10 +- grid2op/Backend/backend.py | 8 +- grid2op/Backend/educPandaPowerBackend.py | 2 + grid2op/Backend/pandaPowerBackend.py | 10 +- grid2op/Converter/BackendConverter.py | 4 +- grid2op/Environment/baseEnv.py | 4 + grid2op/Environment/environment.py | 13 ++- grid2op/Environment/multiMixEnv.py | 8 +- grid2op/MakeEnv/Make.py | 14 +++ grid2op/MakeEnv/MakeFromPath.py | 5 + grid2op/Observation/baseObservation.py | 4 +- grid2op/Space/GridObjects.py | 34 ++++++- grid2op/Space/__init__.py | 4 +- grid2op/tests/test_shedding.py | 56 +++++++++++ 15 files changed, 275 insertions(+), 23 deletions(-) create mode 100644 getting_started/13_Shedding.ipynb create mode 100644 grid2op/tests/test_shedding.py diff --git a/getting_started/13_Shedding.ipynb b/getting_started/13_Shedding.ipynb new file mode 100644 index 00000000..c70d9143 --- /dev/null +++ b/getting_started/13_Shedding.ipynb @@ -0,0 +1,122 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### Shedding\n", + "In emergency conditions, it may be possible / necessary for a grid operator to disconnect 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 shedding is disabled in all environments, to enable it provide set the environment parameter ALLOW_SHEDDING to True.\n", + "\n", + "Shed load:\n", + "* **Vector** :: $\\text{n\\_load}$\n", + "* **List** :: $[(\\text{load\\_id}, \\text{status})]$\n", + "* **Dictionary** :: $\\text{load\\_name}: \\text{status}$\n", + "\n", + "Shed generator:\n", + "* **Vector** :: $\\text{n\\_gen}$\n", + "* **List** :: $[(\\text{gen\\_id}, \\text{status})]$\n", + "* **Dictionary** :: $\\text{gen\\_name}: \\text{status}$\n", + "\n", + "Where $\\text{status}$ is a boolean (True/False)." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "import numpy as np\n", + "import matplotlib.pyplot as plt\n", + "import grid2op\n", + "from grid2op.Parameters import Parameters\n", + "\n", + "from grid2op.PlotGrid import PlotMatplot\n", + "from pathlib import Path\n", + "\n", + "\n", + "p = Parameters()\n", + "p.MAX_SUB_CHANGED = 5\n", + "\n", + "data_path = Path.cwd() / \"grid2op\" / \"data\"\n", + "env = grid2op.make(data_path / \"rte_case5_example\", allow_shedding=True, n_busbar=3, param=p)\n", + "plotter = PlotMatplot(env.observation_space, load_name=True, gen_name=True, dpi=150)\n", + "env.set_id(\"00\")\n", + "obs = env.reset()\n", + "\n", + "print(f\"Loads: {env.n_load}, Generators: {env.n_gen}, Storage: {env.n_storage}\")\n", + "plotter.plot_obs(obs, figure=plt.figure(figsize=(8,5)))\n", + "plt.show()" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "load_lookup = {name:i for i,name in enumerate(env.name_load)}\n", + "act = env.action_space({\"set_bus\":[(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()" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "load_shed_mask = (init_obs + act).topo_vect[env.load_pos_topo_vect]\n", + "gen_shed_mask = (init_obs + act).topo_vect[env.gen_pos_topo_vect]" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "import pandapower as pp\n", + "from pandapower.plotting import simple_plot\n", + "network = env.backend._grid.deepcopy()\n", + "display(network.res_line.loc[:, [\"p_from_mw\", \"p_to_mw\", \"q_from_mvar\", \"q_to_mvar\"]])\n", + "pp.runpp(network,\n", + " check_connectivity=False,\n", + " init=\"dc\",\n", + " lightsim2grid=False,\n", + " max_iteration=10,\n", + " distributed_slack=False,\n", + ")\n", + "display(network.res_line.loc[:, [\"p_from_mw\", \"p_to_mw\", \"q_from_mvar\", \"q_to_mvar\"]])" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "venv_grid2op", + "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 1b54e77e..3e2a2901 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)() @@ -1098,7 +1098,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: diff --git a/grid2op/Backend/backend.py b/grid2op/Backend/backend.py index b71c8532..84dd0d5f 100644 --- a/grid2op/Backend/backend.py +++ b/grid2op/Backend/backend.py @@ -34,7 +34,7 @@ DivergingPowerflow, Grid2OpException, ) -from grid2op.Space import GridObjects, DEFAULT_N_BUSBAR_PER_SUB +from grid2op.Space import GridObjects, DEFAULT_N_BUSBAR_PER_SUB, DEFAULT_ALLOW_SHEDDING # TODO method to get V and theta at each bus, could be in the same shape as check_kirchoff @@ -122,8 +122,8 @@ class Backend(GridObjects, ABC): ERR_INIT_POWERFLOW : str = "Power cannot be computed on the first time step, please check your data." 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, allow_shedding:bool=DEFAULT_ALLOW_SHEDDING, **kwargs): """ Initialize an instance of Backend. This does nothing per se. Only the call to :func:`Backend.load_grid` @@ -179,6 +179,8 @@ 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 + + self.allow_shedding: bool = allow_shedding def can_handle_more_than_2_busbar(self): """ diff --git a/grid2op/Backend/educPandaPowerBackend.py b/grid2op/Backend/educPandaPowerBackend.py index a56d6600..4520b1c4 100644 --- a/grid2op/Backend/educPandaPowerBackend.py +++ b/grid2op/Backend/educPandaPowerBackend.py @@ -67,6 +67,7 @@ class EducPandaPowerBackend(Backend): def __init__(self, detailed_infos_for_cascading_failures : Optional[bool]=False, + allow_shedding:bool=False, can_be_copied : Optional[bool]=True): """ Nothing much to do here except initializing what you would need (a tensorflow session, link to some @@ -83,6 +84,7 @@ def __init__(self, self, detailed_infos_for_cascading_failures=detailed_infos_for_cascading_failures, can_be_copied=can_be_copied, + allow_shedding=allow_shedding, # extra arguments that might be needed for building such a backend # these extra kwargs will be stored (without copy) in the # base class and used when another backend will be created diff --git a/grid2op/Backend/pandaPowerBackend.py b/grid2op/Backend/pandaPowerBackend.py index 98711ce4..677fd47c 100644 --- a/grid2op/Backend/pandaPowerBackend.py +++ b/grid2op/Backend/pandaPowerBackend.py @@ -122,6 +122,7 @@ def __init__( max_iter : int=10, can_be_copied: bool=True, with_numba: bool=NUMBA_, + allow_shedding:bool=False, ): from grid2op.MakeEnv.Make import _force_test_dataset if _force_test_dataset(): @@ -136,7 +137,8 @@ def __init__( lightsim2grid=lightsim2grid, dist_slack=dist_slack, max_iter=max_iter, - with_numba=with_numba + with_numba=with_numba, + allow_shedding=allow_shedding, ) self.with_numba : bool = with_numba self.prod_pu_to_kv : Optional[np.ndarray] = None @@ -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]) @@ -1024,14 +1026,14 @@ def _aux_runpf_pp(self, is_dc: bool): # else: # self._pf_init = "auto" - if (~self._grid.load["in_service"]).any(): + if not self.allow_shedding and (~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(): + if not self.allow_shedding and (~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" diff --git a/grid2op/Converter/BackendConverter.py b/grid2op/Converter/BackendConverter.py index 4c023c85..1dda02fe 100644 --- a/grid2op/Converter/BackendConverter.py +++ b/grid2op/Converter/BackendConverter.py @@ -95,6 +95,7 @@ def __init__( use_target_backend_name=False, kwargs_target_backend=None, kwargs_source_backend=None, + allow_shedding:bool=False, ): Backend.__init__( self, @@ -102,6 +103,7 @@ def __init__( use_target_backend_name=use_target_backend_name, kwargs_target_backend=kwargs_target_backend, kwargs_source_backend=kwargs_source_backend, + allow_shedding=allow_shedding, ) difcf = detailed_infos_for_cascading_failures if kwargs_source_backend is None: @@ -165,7 +167,7 @@ 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_SHEDDING 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) self.cannot_handle_more_than_2_busbar() diff --git a/grid2op/Environment/baseEnv.py b/grid2op/Environment/baseEnv.py index 5d8e76a2..448a867e 100644 --- a/grid2op/Environment/baseEnv.py +++ b/grid2op/Environment/baseEnv.py @@ -334,6 +334,7 @@ def __init__( highres_sim_counter=None, update_obs_after_reward=False, n_busbar=2, + allow_shedding:bool=False, _is_test: bool = False, # TODO not implemented !! _init_obs: Optional[BaseObservation] =None, _local_dir_cls=None, @@ -360,6 +361,8 @@ def __init__( self._raw_backend_class = _raw_backend_class self._n_busbar = n_busbar # env attribute not class attribute ! + self.allow_shedding = allow_shedding + if other_rewards is None: other_rewards = {} if kwargs_attention_budget is None: @@ -656,6 +659,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_shedding = self.allow_shedding new_obj._init_grid_path = copy.deepcopy(self._init_grid_path) new_obj._init_env_path = copy.deepcopy(self._init_env_path) diff --git a/grid2op/Environment/environment.py b/grid2op/Environment/environment.py index 164e7203..1955f976 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_SHEDDING from grid2op.typing_variables import RESET_OPTIONS_TYPING, N_BUSBAR_PER_SUB_TYPING from grid2op.MakeEnv.PathUtils import USE_CLASS_IN_FILE @@ -86,6 +86,7 @@ def __init__( parameters, name="unknown", n_busbar : N_BUSBAR_PER_SUB_TYPING=DEFAULT_N_BUSBAR_PER_SUB, + allow_shedding:bool=DEFAULT_ALLOW_SHEDDING, names_chronics_to_backend=None, actionClass=TopologyAction, observationClass=CompleteObservation, @@ -156,6 +157,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_shedding=allow_shedding, name=name, _raw_backend_class=_raw_backend_class if _raw_backend_class is not None else type(backend), _init_obs=_init_obs, @@ -278,8 +280,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) + 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 @@ -299,6 +303,9 @@ def _init_backend( f'not be able to use the renderer, plot the grid etc. The error was "{exc_}"' ) + # Shedding + self.backend.allow_shedding = self.allow_shedding + # alarm set up self.load_alarm_data() self.load_alert_data() @@ -2204,7 +2211,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_shedding=DEFAULT_ALLOW_SHEDDING, ): res = cls(init_env_path=init_env_path, init_grid_path=init_grid_path, @@ -2237,6 +2245,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_shedding=bool(allow_shedding), _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 be250847..b5ef41f9 100644 --- a/grid2op/Environment/multiMixEnv.py +++ b/grid2op/Environment/multiMixEnv.py @@ -13,7 +13,7 @@ from typing import Any, Dict, Tuple, Union, List, Literal from grid2op.dtypes import dt_int, dt_float -from grid2op.Space import GridObjects, RandomObject, DEFAULT_N_BUSBAR_PER_SUB +from grid2op.Space import GridObjects, RandomObject, DEFAULT_N_BUSBAR_PER_SUB, DEFAULT_ALLOW_SHEDDING from grid2op.Exceptions import EnvError, Grid2OpException from grid2op.Observation import BaseObservation from grid2op.MakeEnv.PathUtils import USE_CLASS_IN_FILE @@ -165,6 +165,7 @@ def __init__( logger=None, experimental_read_from_local_dir=None, n_busbar=DEFAULT_N_BUSBAR_PER_SUB, + allow_shedding=DEFAULT_ALLOW_SHEDDING, _add_to_name="", # internal, for test only, do not use ! _compat_glop_version=None, # internal, for test only, do not use ! _test=False, @@ -207,6 +208,7 @@ def __init__( _add_to_name, _compat_glop_version, n_busbar, + allow_shedding, _test, experimental_read_from_local_dir, multi_env_name, @@ -235,6 +237,7 @@ def __init__( _add_to_name, _compat_glop_version, n_busbar, + allow_shedding, _test, experimental_read_from_local_dir, multi_env_name, @@ -301,6 +304,7 @@ def _aux_create_a_mix(self, _add_to_name, _compat_glop_version, n_busbar, + allow_shedding, _test, experimental_read_from_local_dir, multi_env_name, @@ -335,6 +339,7 @@ def _aux_create_a_mix(self, _add_to_name=_add_to_name, _compat_glop_version=_compat_glop_version, n_busbar=n_busbar, + allow_shedding=allow_shedding, test=_test, logger=this_logger, experimental_read_from_local_dir=experimental_read_from_local_dir, @@ -345,6 +350,7 @@ def _aux_create_a_mix(self, mix = make( mix_path, n_busbar=n_busbar, + allow_shedding=allow_shedding, _add_to_name=_add_to_name, _compat_glop_version=_compat_glop_version, test=_test, diff --git a/grid2op/MakeEnv/Make.py b/grid2op/MakeEnv/Make.py index 11a202e5..1e954d72 100644 --- a/grid2op/MakeEnv/Make.py +++ b/grid2op/MakeEnv/Make.py @@ -248,6 +248,7 @@ def _aux_make_multimix( test=False, experimental_read_from_local_dir=False, n_busbar=2, + allow_shedding=False, _add_to_name="", _compat_glop_version=None, _overload_name_multimix=None, @@ -262,6 +263,7 @@ def _aux_make_multimix( dataset_path, experimental_read_from_local_dir=experimental_read_from_local_dir, n_busbar=n_busbar, + allow_shedding=allow_shedding, _test=test, _add_to_name=_add_to_name, _compat_glop_version=_compat_glop_version, @@ -286,6 +288,7 @@ def make( logger: Optional[logging.Logger]=None, experimental_read_from_local_dir : bool=False, n_busbar=2, + allow_shedding=False, _add_to_name : str="", _compat_glop_version : Optional[str]=None, _overload_name_multimix : Optional[str]=None, # do not use ! @@ -304,6 +307,9 @@ def make( .. versionadded:: 1.10.0 The `n_busbar` parameters + + .. versionadded:: 1.11.0 + The `allow_shedding` parameter Parameters ---------- @@ -328,6 +334,9 @@ def make( n_busbar: ``int`` Number of independant busbars allowed per substations. By default it's 2. + + allow_shedding: ``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 @@ -383,6 +392,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: @@ -436,6 +446,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_shedding=allow_shedding, **kwargs ) @@ -482,6 +493,7 @@ def make_from_path_fn_(*args, **kwargs): dataset_path=ds_path, logger=logger, n_busbar=n_busbar, + allow_shedding=allow_shedding, _add_to_name=_add_to_name, _compat_glop_version=_compat_glop_version, experimental_read_from_local_dir=experimental_read_from_local_dir, @@ -497,6 +509,7 @@ def make_from_path_fn_(*args, **kwargs): real_ds_path, logger=logger, n_busbar=n_busbar, + allow_shedding=allow_shedding, experimental_read_from_local_dir=experimental_read_from_local_dir, _overload_name_multimix=_overload_name_multimix, **kwargs @@ -517,6 +530,7 @@ def make_from_path_fn_(*args, **kwargs): dataset_path=real_ds_path, logger=logger, n_busbar=n_busbar, + allow_shedding=allow_shedding, experimental_read_from_local_dir=experimental_read_from_local_dir, _overload_name_multimix=_overload_name_multimix, **kwargs diff --git a/grid2op/MakeEnv/MakeFromPath.py b/grid2op/MakeEnv/MakeFromPath.py index c051bf67..c966e465 100644 --- a/grid2op/MakeEnv/MakeFromPath.py +++ b/grid2op/MakeEnv/MakeFromPath.py @@ -127,6 +127,7 @@ def make_from_dataset_path( logger=None, experimental_read_from_local_dir=False, n_busbar=2, + allow_shedding=False, _add_to_name="", _compat_glop_version=None, _overload_name_multimix=None, @@ -165,6 +166,8 @@ def make_from_dataset_path( n_busbar: ``int`` Number of independant busbars allowed per substations. By default it's 2. + + allow_shedding; ``bool`` action_class: ``type``, optional Type of BaseAction the BaseAgent will be able to perform. @@ -951,6 +954,7 @@ def make_from_dataset_path( 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) + allow_shedding=allow_shedding, _compat_glop_version=_compat_glop_version, _read_from_local_dir=None, # first environment to generate the classes and save them _local_dir_cls=None, @@ -1031,6 +1035,7 @@ def make_from_dataset_path( 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) + allow_shedding=allow_shedding, _compat_glop_version=_compat_glop_version, _read_from_local_dir=classes_path, _allow_loaded_backend=allow_loaded_backend, diff --git a/grid2op/Observation/baseObservation.py b/grid2op/Observation/baseObservation.py index 10f36207..d03406cb 100644 --- a/grid2op/Observation/baseObservation.py +++ b/grid2op/Observation/baseObservation.py @@ -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): diff --git a/grid2op/Space/GridObjects.py b/grid2op/Space/GridObjects.py index f6c84dd4..24095ce4 100644 --- a/grid2op/Space/GridObjects.py +++ b/grid2op/Space/GridObjects.py @@ -33,6 +33,7 @@ # TODO tests of these methods and this class in general DEFAULT_N_BUSBAR_PER_SUB = 2 +DEFAULT_ALLOW_SHEDDING = False class GridObjects: @@ -513,6 +514,7 @@ class GridObjects: sub_info : ClassVar[np.ndarray] = None dim_topo : ClassVar[np.ndarray] = -1 + allow_shedding : ClassVar[bool] = DEFAULT_ALLOW_SHEDDING # to which substation is connected each element load_to_subid : ClassVar[np.ndarray] = None @@ -685,6 +687,7 @@ def _clear_class_attribute(cls) -> None: """ cls.shunts_data_available = False cls.n_busbar_per_sub = DEFAULT_N_BUSBAR_PER_SUB + cls.allow_shedding = DEFAULT_ALLOW_SHEDDING # for redispatching / unit commitment cls._li_attr_disp = [ @@ -2332,6 +2335,9 @@ def assert_grid_correct_cls(cls): # alert data cls._check_validity_alert_data() + # shedding + assert isinstance(cls.allow_shedding, bool) + @classmethod def _check_validity_alarm_data(cls): if cls.dim_alarms == 0: @@ -3013,7 +3019,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 +3091,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"): + # Shedding did not exist, default value should have + # no effect + cls.allow_shedding = DEFAULT_ALLOW_SHEDDING + res = True if res: cls._reset_cls_dict() # forget the previous class (stored as dict) @@ -4060,6 +4072,10 @@ 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_ ) + + # Shedding + save_to_dict(res, cls, "allow_shedding", str, copy_) + # avoid further computation and save it if not as_list: cls._CLS_DICT = res.copy() @@ -4114,6 +4130,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 + + # Shedding + res["allow_shedding"] = cls.allow_shedding # avoid further computation and save it if not as_list and not _topo_vect_only: @@ -4360,7 +4379,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 @@ -4397,6 +4416,12 @@ class res(GridObjects): cls.alertable_line_names = [] cls.alertable_line_ids = [] + # Shedding + if 'allow_shedding' in dict_: + cls.allow_shedding = int(dict_["allow_shedding"]) + else: # Compatibility for older versions + cls.allow_shedding = DEFAULT_ALLOW_SHEDDING + # save the representation of this class as dict tmp = {} cls._make_cls_dict_extended(cls, tmp, as_list=False, copy_=True) @@ -4408,7 +4433,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 @@ -5043,6 +5068,9 @@ class {cls.__name__}({cls._INIT_GRID_CLS.__name__}): alertable_line_names = {alertable_line_names_str} alertable_line_ids = {alertable_line_ids_str} + # shedding + allow_shedding = {cls.allow_shedding} + """ return res diff --git a/grid2op/Space/__init__.py b/grid2op/Space/__init__.py index 69387627..3cc3d895 100644 --- a/grid2op/Space/__init__.py +++ b/grid2op/Space/__init__.py @@ -1,5 +1,5 @@ -__all__ = ["RandomObject", "SerializableSpace", "GridObjects", "DEFAULT_N_BUSBAR_PER_SUB"] +__all__ = ["RandomObject", "SerializableSpace", "GridObjects", "DEFAULT_N_BUSBAR_PER_SUB", "DEFAULT_ALLOW_SHEDDING"] from grid2op.Space.RandomObject import RandomObject from grid2op.Space.SerializableSpace import SerializableSpace -from grid2op.Space.GridObjects import GridObjects, DEFAULT_N_BUSBAR_PER_SUB +from grid2op.Space.GridObjects import GridObjects, DEFAULT_N_BUSBAR_PER_SUB, DEFAULT_ALLOW_SHEDDING diff --git a/grid2op/tests/test_shedding.py b/grid2op/tests/test_shedding.py new file mode 100644 index 00000000..af98fca1 --- /dev/null +++ b/grid2op/tests/test_shedding.py @@ -0,0 +1,56 @@ +import warnings +import unittest +import grid2op +from grid2op.Parameters import Parameters + +class TestShedding(unittest.TestCase): + + def setUp(self) -> None: + super().setUp() + p = Parameters() + p.ALLOW_SHEDDING = True + p.MAX_SUB_CHANGED = 5 + with warnings.catch_warnings(): + warnings.filterwarnings("ignore") + self.env = grid2op.make("rte_case5_example", param=p, test=True) + + def tearDown(self) -> None: + self.env.close() + + def test_shedding_parameter_is_true(self): + assert self.env.parameters.ALLOW_SHEDDING is True + + def test_shed_single_load(self): + # Check that a single load can be shed + pass + + def test_shed_single_generator(self): + # Check that a single generator can be shed + pass + + def test_shed_multiple_loads(self): + # Check that multiple loads can be shed at the same time + pass + + def test_shed_multiple_generators(self): + # Check that multiple generators can be shed at the same time + pass + + def test_shed_load_and_generator(self): + # Check that load and generator can be shed at the same time + pass + + def test_shed_energy_storage(self): + # Check that energy storage device can be shed successfully + pass + + def test_shedding_appears_in_observation(self): + # Check that observation contains correct information about shedding + pass + + def test_shedding_persistance(self): + # Check that components remains disconnected if shed + pass + +if __name__ == "__main__": + unittest.main() From 75cf5a7d1e38650f5683f58fea9db05e419ec9ea Mon Sep 17 00:00:00 2001 From: Xavier Weiss Date: Mon, 4 Nov 2024 11:48:57 +0100 Subject: [PATCH 02/39] Debug: GridObject AllowShedding conversion Signed-off-by: Xavier Weiss --- grid2op/Space/GridObjects.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/grid2op/Space/GridObjects.py b/grid2op/Space/GridObjects.py index 24095ce4..d9ce4cf1 100644 --- a/grid2op/Space/GridObjects.py +++ b/grid2op/Space/GridObjects.py @@ -4418,7 +4418,7 @@ class res(GridObjects): # Shedding if 'allow_shedding' in dict_: - cls.allow_shedding = int(dict_["allow_shedding"]) + cls.allow_shedding = bool(dict_["allow_shedding"]) else: # Compatibility for older versions cls.allow_shedding = DEFAULT_ALLOW_SHEDDING From 55e8c411b1763a24699071c09b6ebc7d328fde5e Mon Sep 17 00:00:00 2001 From: Xavier Weiss Date: Mon, 4 Nov 2024 13:22:23 +0100 Subject: [PATCH 03/39] Add: Exception if shedding is not supported by Backend Signed-off-by: Xavier Weiss --- getting_started/13_Shedding.ipynb | 19 ++++++------------- grid2op/Backend/backend.py | 11 ++++++++++- grid2op/Backend/pandaPowerBackend.py | 4 ++++ grid2op/Environment/environment.py | 2 +- 4 files changed, 21 insertions(+), 15 deletions(-) diff --git a/getting_started/13_Shedding.ipynb b/getting_started/13_Shedding.ipynb index c70d9143..e5a11cbe 100644 --- a/getting_started/13_Shedding.ipynb +++ b/getting_started/13_Shedding.ipynb @@ -31,24 +31,16 @@ "import numpy as np\n", "import matplotlib.pyplot as plt\n", "import grid2op\n", + "from lightsim2grid import LightSimBackend\n", "from grid2op.Parameters import Parameters\n", "\n", "from grid2op.PlotGrid import PlotMatplot\n", "from pathlib import Path\n", "\n", - "\n", - "p = Parameters()\n", - "p.MAX_SUB_CHANGED = 5\n", - "\n", "data_path = Path.cwd() / \"grid2op\" / \"data\"\n", - "env = grid2op.make(data_path / \"rte_case5_example\", allow_shedding=True, n_busbar=3, param=p)\n", + "env = grid2op.make(data_path / \"rte_case5_example\", allow_shedding=True)\n", "plotter = PlotMatplot(env.observation_space, load_name=True, gen_name=True, dpi=150)\n", - "env.set_id(\"00\")\n", - "obs = env.reset()\n", - "\n", - "print(f\"Loads: {env.n_load}, Generators: {env.n_gen}, Storage: {env.n_storage}\")\n", - "plotter.plot_obs(obs, figure=plt.figure(figsize=(8,5)))\n", - "plt.show()" + "print(f\"Loads: {env.n_load}, Generators: {env.n_gen}, Storage: {env.n_storage}, Allow Shedding: {env.allow_shedding}\")" ] }, { @@ -57,6 +49,7 @@ "metadata": {}, "outputs": [], "source": [ + "# Disconnect the load at substation 4\n", "load_lookup = {name:i for i,name in enumerate(env.name_load)}\n", "act = env.action_space({\"set_bus\":[(env.load_pos_topo_vect[load_lookup[\"load_4_2\"]], -1)]})\n", "print(act)\n", @@ -73,8 +66,8 @@ "metadata": {}, "outputs": [], "source": [ - "load_shed_mask = (init_obs + act).topo_vect[env.load_pos_topo_vect]\n", - "gen_shed_mask = (init_obs + act).topo_vect[env.gen_pos_topo_vect]" + "load_shed_mask = (init_obs + act).topo_vect[env.load_pos_topo_vect] == -1\n", + "gen_shed_mask = (init_obs + act).topo_vect[env.gen_pos_topo_vect] == -1" ] }, { diff --git a/grid2op/Backend/backend.py b/grid2op/Backend/backend.py index 84dd0d5f..53e09add 100644 --- a/grid2op/Backend/backend.py +++ b/grid2op/Backend/backend.py @@ -180,7 +180,7 @@ def __init__(self, #: 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 - self.allow_shedding: bool = allow_shedding + self.set_shedding(allow_shedding) def can_handle_more_than_2_busbar(self): """ @@ -242,6 +242,15 @@ 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 set_shedding(self, allow_shedding:bool=False) -> bool: + """ + Override if the Backend supports shedding. + """ + if allow_shedding: + raise BackendError("Backend does not support shedding") + else: + self.allow_shedding = allow_shedding def make_complete_path(self, path : Union[os.PathLike, str], diff --git a/grid2op/Backend/pandaPowerBackend.py b/grid2op/Backend/pandaPowerBackend.py index 677fd47c..9e5cf180 100644 --- a/grid2op/Backend/pandaPowerBackend.py +++ b/grid2op/Backend/pandaPowerBackend.py @@ -1548,3 +1548,7 @@ 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 + + def set_shedding(self, allow_shedding:bool=False) -> bool: + # Supported as of Grid2Op 1.11.0 + self.allow_shedding = allow_shedding \ No newline at end of file diff --git a/grid2op/Environment/environment.py b/grid2op/Environment/environment.py index 1955f976..dfb87473 100644 --- a/grid2op/Environment/environment.py +++ b/grid2op/Environment/environment.py @@ -304,7 +304,7 @@ def _init_backend( ) # Shedding - self.backend.allow_shedding = self.allow_shedding + self.backend.set_shedding(self.allow_shedding) # alarm set up self.load_alarm_data() From 51eb97ee0102dec4692fb9c1ddff895c6400e898 Mon Sep 17 00:00:00 2001 From: Xavier Weiss Date: Mon, 4 Nov 2024 13:30:07 +0100 Subject: [PATCH 04/39] Refact: Remove bool return from set_shedding() method Signed-off-by: Xavier Weiss --- grid2op/Backend/backend.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/grid2op/Backend/backend.py b/grid2op/Backend/backend.py index 53e09add..7e535af3 100644 --- a/grid2op/Backend/backend.py +++ b/grid2op/Backend/backend.py @@ -243,7 +243,7 @@ def cannot_handle_more_than_2_busbar(self): "upgrade it to a newer version.") self.n_busbar_per_sub = DEFAULT_N_BUSBAR_PER_SUB - def set_shedding(self, allow_shedding:bool=False) -> bool: + def set_shedding(self, allow_shedding:bool=False): """ Override if the Backend supports shedding. """ From 623d04bb8fe0c091a24ea22d3041ceaaf02238aa Mon Sep 17 00:00:00 2001 From: Xavier Weiss Date: Mon, 4 Nov 2024 19:12:57 +0100 Subject: [PATCH 05/39] Test: Add Entry for Allow_Shedding Signed-off-by: Xavier Weiss --- getting_started/13_Shedding.ipynb | 28 ++++------------------------ grid2op/Backend/backend.py | 1 + grid2op/Space/GridObjects.py | 6 +++--- grid2op/tests/test_Observation.py | 1 + 4 files changed, 9 insertions(+), 27 deletions(-) diff --git a/getting_started/13_Shedding.ipynb b/getting_started/13_Shedding.ipynb index e5a11cbe..78605dcf 100644 --- a/getting_started/13_Shedding.ipynb +++ b/getting_started/13_Shedding.ipynb @@ -7,19 +7,7 @@ "### Shedding\n", "In emergency conditions, it may be possible / necessary for a grid operator to disconnect 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 shedding is disabled in all environments, to enable it provide set the environment parameter ALLOW_SHEDDING to True.\n", - "\n", - "Shed load:\n", - "* **Vector** :: $\\text{n\\_load}$\n", - "* **List** :: $[(\\text{load\\_id}, \\text{status})]$\n", - "* **Dictionary** :: $\\text{load\\_name}: \\text{status}$\n", - "\n", - "Shed generator:\n", - "* **Vector** :: $\\text{n\\_gen}$\n", - "* **List** :: $[(\\text{gen\\_id}, \\text{status})]$\n", - "* **Dictionary** :: $\\text{gen\\_name}: \\text{status}$\n", - "\n", - "Where $\\text{status}$ is a boolean (True/False)." + "By default shedding is disabled in all environments, to provide the keyword argument allow_shedding when initializing the environment." ] }, { @@ -38,7 +26,7 @@ "from pathlib import Path\n", "\n", "data_path = Path.cwd() / \"grid2op\" / \"data\"\n", - "env = grid2op.make(data_path / \"rte_case5_example\", allow_shedding=True)\n", + "env = grid2op.make(data_path / \"rte_case5_example\", backend=LightSimBackend(), allow_shedding=True)\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 Shedding: {env.allow_shedding}\")" ] @@ -51,7 +39,9 @@ "source": [ "# Disconnect the load at substation 4\n", "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_4_2\"]], -1)]})\n", + "act = env.action_space({\"set_bus\":[(env.gen_pos_topo_vect[gen_lookup[\"gen_0_0\"]], -1)]})\n", "print(act)\n", "env.set_id(\"00\")\n", "init_obs = env.reset()\n", @@ -60,16 +50,6 @@ "plt.show()" ] }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "load_shed_mask = (init_obs + act).topo_vect[env.load_pos_topo_vect] == -1\n", - "gen_shed_mask = (init_obs + act).topo_vect[env.gen_pos_topo_vect] == -1" - ] - }, { "cell_type": "code", "execution_count": null, diff --git a/grid2op/Backend/backend.py b/grid2op/Backend/backend.py index 7e535af3..378945d3 100644 --- a/grid2op/Backend/backend.py +++ b/grid2op/Backend/backend.py @@ -247,6 +247,7 @@ def set_shedding(self, allow_shedding:bool=False): """ Override if the Backend supports shedding. """ + if allow_shedding: raise BackendError("Backend does not support shedding") else: diff --git a/grid2op/Space/GridObjects.py b/grid2op/Space/GridObjects.py index d9ce4cf1..5d854318 100644 --- a/grid2op/Space/GridObjects.py +++ b/grid2op/Space/GridObjects.py @@ -3930,6 +3930,9 @@ def _make_cls_dict(cls, res, as_list=True, copy_=True, _topo_vect_only=False): else: res["name_shunt"] = None res["shunt_to_subid"] = None + + # Shedding + save_to_dict(res, cls, "allow_shedding", bool, copy_) if not _topo_vect_only: # all the attributes bellow are not needed for the "first call" @@ -4072,9 +4075,6 @@ 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_ ) - - # Shedding - save_to_dict(res, cls, "allow_shedding", str, copy_) # avoid further computation and save it if not as_list: diff --git a/grid2op/tests/test_Observation.py b/grid2op/tests/test_Observation.py index dff0b205..ed8feb0a 100644 --- a/grid2op/tests/test_Observation.py +++ b/grid2op/tests/test_Observation.py @@ -299,6 +299,7 @@ def setUp(self): "alertable_line_ids": [], "assistant_warning_type": None, "_PATH_GRID_CLASSES": None, + "allow_shedding": False, } self.json_ref = { From ccbd1db95f50bfce0c9bc8cd3902e7814266a94f Mon Sep 17 00:00:00 2001 From: Xavier Weiss Date: Mon, 4 Nov 2024 20:48:14 +0100 Subject: [PATCH 06/39] Refact: Remove Allow_Shedding from dict repr of GridObject Signed-off-by: Xavier Weiss --- grid2op/Space/GridObjects.py | 5 ----- grid2op/tests/test_Observation.py | 1 - 2 files changed, 6 deletions(-) diff --git a/grid2op/Space/GridObjects.py b/grid2op/Space/GridObjects.py index 5d854318..a31f8b68 100644 --- a/grid2op/Space/GridObjects.py +++ b/grid2op/Space/GridObjects.py @@ -3931,8 +3931,6 @@ def _make_cls_dict(cls, res, as_list=True, copy_=True, _topo_vect_only=False): res["name_shunt"] = None res["shunt_to_subid"] = None - # Shedding - save_to_dict(res, cls, "allow_shedding", bool, copy_) if not _topo_vect_only: # all the attributes bellow are not needed for the "first call" @@ -4131,9 +4129,6 @@ 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 - # Shedding - res["allow_shedding"] = cls.allow_shedding - # avoid further computation and save it if not as_list and not _topo_vect_only: cls._CLS_DICT_EXTENDED = res.copy() diff --git a/grid2op/tests/test_Observation.py b/grid2op/tests/test_Observation.py index ed8feb0a..dff0b205 100644 --- a/grid2op/tests/test_Observation.py +++ b/grid2op/tests/test_Observation.py @@ -299,7 +299,6 @@ def setUp(self): "alertable_line_ids": [], "assistant_warning_type": None, "_PATH_GRID_CLASSES": None, - "allow_shedding": False, } self.json_ref = { From 9c0078eabb8d5afc7633ead41bf35153522022ca Mon Sep 17 00:00:00 2001 From: Xavier Weiss Date: Tue, 5 Nov 2024 08:53:33 +0100 Subject: [PATCH 07/39] Add: Unit Tests Signed-off-by: Xavier Weiss --- getting_started/13_Shedding.ipynb | 10 ++-- grid2op/tests/test_shedding.py | 79 +++++++++++++++++++++++-------- 2 files changed, 65 insertions(+), 24 deletions(-) diff --git a/getting_started/13_Shedding.ipynb b/getting_started/13_Shedding.ipynb index 78605dcf..cba22e40 100644 --- a/getting_started/13_Shedding.ipynb +++ b/getting_started/13_Shedding.ipynb @@ -26,7 +26,9 @@ "from pathlib import Path\n", "\n", "data_path = Path.cwd() / \"grid2op\" / \"data\"\n", - "env = grid2op.make(data_path / \"rte_case5_example\", backend=LightSimBackend(), allow_shedding=True)\n", + "p = Parameters()\n", + "p.MAX_SUB_CHANGED = 5\n", + "env = grid2op.make(data_path / \"rte_case5_example\", param=p, allow_shedding=True)\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 Shedding: {env.allow_shedding}\")" ] @@ -40,8 +42,9 @@ "# Disconnect the load at substation 4\n", "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_4_2\"]], -1)]})\n", - "act = env.action_space({\"set_bus\":[(env.gen_pos_topo_vect[gen_lookup[\"gen_0_0\"]], -1)]})\n", + "act = env.action_space({\"set_bus\":[(env.load_pos_topo_vect[load_lookup[\"load_4_2\"]], -1),\n", + " (env.load_pos_topo_vect[load_lookup[\"load_3_1\"]], -1)]})\n", + "# act = env.action_space({\"set_bus\":[(env.gen_pos_topo_vect[gen_lookup[\"gen_0_0\"]], -1)]})\n", "print(act)\n", "env.set_id(\"00\")\n", "init_obs = env.reset()\n", @@ -57,7 +60,6 @@ "outputs": [], "source": [ "import pandapower as pp\n", - "from pandapower.plotting import simple_plot\n", "network = env.backend._grid.deepcopy()\n", "display(network.res_line.loc[:, [\"p_from_mw\", \"p_to_mw\", \"q_from_mvar\", \"q_to_mvar\"]])\n", "pp.runpp(network,\n", diff --git a/grid2op/tests/test_shedding.py b/grid2op/tests/test_shedding.py index af98fca1..f79f1252 100644 --- a/grid2op/tests/test_shedding.py +++ b/grid2op/tests/test_shedding.py @@ -8,49 +8,88 @@ class TestShedding(unittest.TestCase): def setUp(self) -> None: super().setUp() p = Parameters() - p.ALLOW_SHEDDING = True p.MAX_SUB_CHANGED = 5 with warnings.catch_warnings(): warnings.filterwarnings("ignore") - self.env = grid2op.make("rte_case5_example", param=p, test=True) + self.env = grid2op.make("rte_case5_example", param=p, + allow_shedding=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.parameters.ALLOW_SHEDDING is True + assert hasattr(self.env, "allow_shedding") + assert self.env.allow_shedding is True def test_shed_single_load(self): # Check that a single load can be shed - pass + 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 - pass + 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 - pass - - def test_shed_multiple_generators(self): - # Check that multiple generators can be shed at the same time - pass + 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 - pass - - def test_shed_energy_storage(self): - # Check that energy storage device can be shed successfully - pass - - def test_shedding_appears_in_observation(self): - # Check that observation contains correct information about shedding - pass + # 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 - pass + 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() From 90c96d8a71109b19e7e14432010b67df24142cbe Mon Sep 17 00:00:00 2001 From: Xavier Weiss Date: Wed, 6 Nov 2024 15:41:40 +0100 Subject: [PATCH 08/39] Refact: Use DEFAULT constants for n_busbar and allow_shedding Signed-off-by: Xavier Weiss --- grid2op/MakeEnv/Make.py | 9 +++++---- grid2op/MakeEnv/MakeFromPath.py | 5 +++-- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/grid2op/MakeEnv/Make.py b/grid2op/MakeEnv/Make.py index 1e954d72..cedefc8f 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_SHEDDING, DEFAULT_N_BUSBAR_PER_SUB _VAR_FORCE_TEST = "_GRID2OP_FORCE_TEST" @@ -247,8 +248,8 @@ def _aux_make_multimix( dataset_path, test=False, experimental_read_from_local_dir=False, - n_busbar=2, - allow_shedding=False, + n_busbar=DEFAULT_N_BUSBAR_PER_SUB, + allow_shedding=DEFAULT_ALLOW_SHEDDING, _add_to_name="", _compat_glop_version=None, _overload_name_multimix=None, @@ -287,8 +288,8 @@ def make( test : bool=False, logger: Optional[logging.Logger]=None, experimental_read_from_local_dir : bool=False, - n_busbar=2, - allow_shedding=False, + n_busbar=DEFAULT_N_BUSBAR_PER_SUB, + allow_shedding=DEFAULT_ALLOW_SHEDDING, _add_to_name : str="", _compat_glop_version : Optional[str]=None, _overload_name_multimix : Optional[str]=None, # do not use ! diff --git a/grid2op/MakeEnv/MakeFromPath.py b/grid2op/MakeEnv/MakeFromPath.py index c966e465..4ca56397 100644 --- a/grid2op/MakeEnv/MakeFromPath.py +++ b/grid2op/MakeEnv/MakeFromPath.py @@ -33,6 +33,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_SHEDDING from grid2op.MakeEnv.get_default_aux import _get_default_aux from grid2op.MakeEnv.PathUtils import _aux_fix_backend_internal_classes @@ -126,8 +127,8 @@ def make_from_dataset_path( dataset_path="/", logger=None, experimental_read_from_local_dir=False, - n_busbar=2, - allow_shedding=False, + n_busbar=DEFAULT_N_BUSBAR_PER_SUB, + allow_shedding=DEFAULT_ALLOW_SHEDDING, _add_to_name="", _compat_glop_version=None, _overload_name_multimix=None, From 50b7c0f6a6532a8909e1dfbef769096113c21adf Mon Sep 17 00:00:00 2001 From: Xavier Weiss Date: Wed, 6 Nov 2024 16:00:00 +0100 Subject: [PATCH 09/39] Add: Can/Cannot Handle Interface for Load/Gen detachment Signed-off-by: Xavier Weiss --- getting_started/13_Shedding.ipynb | 8 +- grid2op/Backend/backend.py | 93 ++++++++++++++++++++++-- grid2op/Backend/educPandaPowerBackend.py | 4 +- grid2op/Backend/pandaPowerBackend.py | 37 ++++------ grid2op/Converter/BackendConverter.py | 6 +- grid2op/Environment/baseEnv.py | 6 +- grid2op/Environment/environment.py | 12 +-- grid2op/Environment/multiMixEnv.py | 14 ++-- grid2op/MakeEnv/Make.py | 20 ++--- grid2op/MakeEnv/MakeFromPath.py | 10 +-- grid2op/Space/GridObjects.py | 18 ++--- grid2op/Space/__init__.py | 4 +- grid2op/tests/test_shedding.py | 4 +- 13 files changed, 156 insertions(+), 80 deletions(-) diff --git a/getting_started/13_Shedding.ipynb b/getting_started/13_Shedding.ipynb index cba22e40..ea56ff16 100644 --- a/getting_started/13_Shedding.ipynb +++ b/getting_started/13_Shedding.ipynb @@ -7,10 +7,10 @@ "### Shedding\n", "In emergency conditions, it may be possible / necessary for a grid operator to disconnect 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 shedding is disabled in all environments, to provide the keyword argument allow_shedding when initializing the environment." + "By default shedding is disabled in all environments, to provide the keyword argument allow_detachment when initializing the environment." ] }, - { + {allow_detachment "cell_type": "code", "execution_count": null, "metadata": {}, @@ -28,9 +28,9 @@ "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_shedding=True)\n", + "env = grid2op.make(data_path / \"rte_case5_example\", param=p, allow_detachment=True)\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 Shedding: {env.allow_shedding}\")" + "print(f\"Loads: {env.n_load}, Generators: {env.n_gen}, Storage: {env.n_storage}, Allow Detachment: {env.allow_detachment}\")" ] }, { diff --git a/grid2op/Backend/backend.py b/grid2op/Backend/backend.py index 378945d3..f448a7e2 100644 --- a/grid2op/Backend/backend.py +++ b/grid2op/Backend/backend.py @@ -34,7 +34,7 @@ DivergingPowerflow, Grid2OpException, ) -from grid2op.Space import GridObjects, DEFAULT_N_BUSBAR_PER_SUB, DEFAULT_ALLOW_SHEDDING +from grid2op.Space import GridObjects, 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 @@ -123,7 +123,7 @@ class Backend(GridObjects, ABC): ERR_INIT_POWERFLOW : str = "Power cannot be computed on the first time step, please check your data." def __init__(self, detailed_infos_for_cascading_failures:bool=False, - can_be_copied:bool=True, allow_shedding:bool=DEFAULT_ALLOW_SHEDDING, + can_be_copied:bool=True, allow_detachment:bool=DEFAULT_ALLOW_DETACHMENT, **kwargs): """ Initialize an instance of Backend. This does nothing per se. Only the call to :func:`Backend.load_grid` @@ -180,7 +180,9 @@ def __init__(self, #: 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 - self.set_shedding(allow_shedding) + # .. versionadded: 1.11.0 + self._missing_detachment_support:bool = True + self.allow_detachment:bool = DEFAULT_ALLOW_DETACHMENT def can_handle_more_than_2_busbar(self): """ @@ -243,15 +245,73 @@ def cannot_handle_more_than_2_busbar(self): "upgrade it to a newer version.") self.n_busbar_per_sub = DEFAULT_N_BUSBAR_PER_SUB - def set_shedding(self, allow_shedding:bool=False): + + 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 = False + self.allow_detachment = type(self).allow_detachment + + 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 = False + if type(self.allow_detachment != 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.allow_detachment = DEFAULT_ALLOW_DETACHMENT + + def set_shedding(self, allow_detachment:bool=False): """ Override if the Backend supports shedding. """ - if allow_shedding: + if allow_detachment: raise BackendError("Backend does not support shedding") else: - self.allow_shedding = allow_shedding + self.allow_detachment = allow_detachment def make_complete_path(self, path : Union[os.PathLike, str], @@ -2093,6 +2153,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"): + if self._missing_detachment_support: + 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 = False + self.allow_detachment = DEFAULT_ALLOW_DETACHMENT + else: + self._missing_detachment_support = False + self.allow_detachment = DEFAULT_ALLOW_DETACHMENT + warnings.warn("Your backend is missing the `_missing_detachment_support` " + "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/educPandaPowerBackend.py b/grid2op/Backend/educPandaPowerBackend.py index 4520b1c4..57e6afad 100644 --- a/grid2op/Backend/educPandaPowerBackend.py +++ b/grid2op/Backend/educPandaPowerBackend.py @@ -67,7 +67,7 @@ class EducPandaPowerBackend(Backend): def __init__(self, detailed_infos_for_cascading_failures : Optional[bool]=False, - allow_shedding:bool=False, + allow_detachment:bool=False, can_be_copied : Optional[bool]=True): """ Nothing much to do here except initializing what you would need (a tensorflow session, link to some @@ -84,7 +84,7 @@ def __init__(self, self, detailed_infos_for_cascading_failures=detailed_infos_for_cascading_failures, can_be_copied=can_be_copied, - allow_shedding=allow_shedding, + allow_detachment=allow_detachment, # extra arguments that might be needed for building such a backend # these extra kwargs will be stored (without copy) in the # base class and used when another backend will be created diff --git a/grid2op/Backend/pandaPowerBackend.py b/grid2op/Backend/pandaPowerBackend.py index 9e5cf180..82477f25 100644 --- a/grid2op/Backend/pandaPowerBackend.py +++ b/grid2op/Backend/pandaPowerBackend.py @@ -122,7 +122,6 @@ def __init__( max_iter : int=10, can_be_copied: bool=True, with_numba: bool=NUMBA_, - allow_shedding:bool=False, ): from grid2op.MakeEnv.Make import _force_test_dataset if _force_test_dataset(): @@ -138,7 +137,6 @@ def __init__( dist_slack=dist_slack, max_iter=max_iter, with_numba=with_numba, - allow_shedding=allow_shedding, ) self.with_numba : bool = with_numba self.prod_pu_to_kv : Optional[np.ndarray] = None @@ -1026,20 +1024,20 @@ def _aux_runpf_pp(self, is_dc: bool): # else: # self._pf_init = "auto" - if not self.allow_shedding and (~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 not self.allow_shedding and (~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]}" - ) + # if not self.allow_detachment and (~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 not self.allow_detachment and (~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") @@ -1327,6 +1325,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 = self._missing_detachment_support res.div_exception = self.div_exception return res @@ -1547,8 +1546,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 - - def set_shedding(self, allow_shedding:bool=False) -> bool: - # Supported as of Grid2Op 1.11.0 - self.allow_shedding = allow_shedding \ No newline at end of file + return bus_id \ No newline at end of file diff --git a/grid2op/Converter/BackendConverter.py b/grid2op/Converter/BackendConverter.py index 1dda02fe..f00f9953 100644 --- a/grid2op/Converter/BackendConverter.py +++ b/grid2op/Converter/BackendConverter.py @@ -95,7 +95,7 @@ def __init__( use_target_backend_name=False, kwargs_target_backend=None, kwargs_source_backend=None, - allow_shedding:bool=False, + allow_detachmentnt:bool=False, ): Backend.__init__( self, @@ -103,7 +103,7 @@ def __init__( use_target_backend_name=use_target_backend_name, kwargs_target_backend=kwargs_target_backend, kwargs_source_backend=kwargs_source_backend, - allow_shedding=allow_shedding, + allow_detachment=allow_detachmentnt, ) difcf = detailed_infos_for_cascading_failures if kwargs_source_backend is None: @@ -167,7 +167,7 @@ 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, DEFAULT_ALLOW_SHEDDING + 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) self.cannot_handle_more_than_2_busbar() diff --git a/grid2op/Environment/baseEnv.py b/grid2op/Environment/baseEnv.py index 448a867e..ea3de461 100644 --- a/grid2op/Environment/baseEnv.py +++ b/grid2op/Environment/baseEnv.py @@ -334,7 +334,7 @@ def __init__( highres_sim_counter=None, update_obs_after_reward=False, n_busbar=2, - allow_shedding:bool=False, + allow_detachmentnt:bool=False, _is_test: bool = False, # TODO not implemented !! _init_obs: Optional[BaseObservation] =None, _local_dir_cls=None, @@ -361,7 +361,7 @@ def __init__( self._raw_backend_class = _raw_backend_class self._n_busbar = n_busbar # env attribute not class attribute ! - self.allow_shedding = allow_shedding + self.allow_detachment = allow_detachmentnt if other_rewards is None: other_rewards = {} @@ -659,7 +659,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_shedding = self.allow_shedding + new_obj.allow_detachmentnt = 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) diff --git a/grid2op/Environment/environment.py b/grid2op/Environment/environment.py index dfb87473..a39926b5 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, DEFAULT_ALLOW_SHEDDING +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 @@ -86,7 +86,7 @@ def __init__( parameters, name="unknown", n_busbar : N_BUSBAR_PER_SUB_TYPING=DEFAULT_N_BUSBAR_PER_SUB, - allow_shedding:bool=DEFAULT_ALLOW_SHEDDING, + allow_detachmentnt:bool=DEFAULT_ALLOW_DETACHMENT, names_chronics_to_backend=None, actionClass=TopologyAction, observationClass=CompleteObservation, @@ -157,7 +157,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_shedding=allow_shedding, + allow_detachmentnallow_detachmentment, name=name, _raw_backend_class=_raw_backend_class if _raw_backend_class is not None else type(backend), _init_obs=_init_obs, @@ -304,7 +304,7 @@ def _init_backend( ) # Shedding - self.backend.set_shedding(self.allow_shedding) + self.backend.set_shedding(self.allow_detachment) # alarm set up self.load_alarm_data() @@ -2212,7 +2212,7 @@ def init_obj_from_kwargs(cls, _local_dir_cls, _overload_name_multimix, n_busbar=DEFAULT_N_BUSBAR_PER_SUB, - allow_shedding=DEFAULT_ALLOW_SHEDDING, + allow_detachmentnt=DEFAULT_ALLOW_DETACHMENT, ): res = cls(init_env_path=init_env_path, init_grid_path=init_grid_path, @@ -2245,7 +2245,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_shedding=bool(allow_shedding), + allow_detachmentnt=booallow_detachmentment), _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 b5ef41f9..d6948673 100644 --- a/grid2op/Environment/multiMixEnv.py +++ b/grid2op/Environment/multiMixEnv.py @@ -13,7 +13,7 @@ from typing import Any, Dict, Tuple, Union, List, Literal from grid2op.dtypes import dt_int, dt_float -from grid2op.Space import GridObjects, RandomObject, DEFAULT_N_BUSBAR_PER_SUB, DEFAULT_ALLOW_SHEDDING +from grid2op.Space import GridObjects, RandomObject, DEFAULT_N_BUSBAR_PER_SUB, DEFAULT_ALLOW_DETACHMENT from grid2op.Exceptions import EnvError, Grid2OpException from grid2op.Observation import BaseObservation from grid2op.MakeEnv.PathUtils import USE_CLASS_IN_FILE @@ -165,7 +165,7 @@ def __init__( logger=None, experimental_read_from_local_dir=None, n_busbar=DEFAULT_N_BUSBAR_PER_SUB, - allow_shedding=DEFAULT_ALLOW_SHEDDING, + allow_detachment=DEFAULT_ALLOW_DETACHMENT, _add_to_name="", # internal, for test only, do not use ! _compat_glop_version=None, # internal, for test only, do not use ! _test=False, @@ -208,7 +208,7 @@ def __init__( _add_to_name, _compat_glop_version, n_busbar, - allow_shedding, + allow_detachment, _test, experimental_read_from_local_dir, multi_env_name, @@ -237,7 +237,7 @@ def __init__( _add_to_name, _compat_glop_version, n_busbar, - allow_shedding, + allow_detachment, _test, experimental_read_from_local_dir, multi_env_name, @@ -304,7 +304,7 @@ def _aux_create_a_mix(self, _add_to_name, _compat_glop_version, n_busbar, - allow_shedding, + allow_detachment, _test, experimental_read_from_local_dir, multi_env_name, @@ -339,7 +339,7 @@ def _aux_create_a_mix(self, _add_to_name=_add_to_name, _compat_glop_version=_compat_glop_version, n_busbar=n_busbar, - allow_shedding=allow_shedding, + allow_detachment=allow_detachment, test=_test, logger=this_logger, experimental_read_from_local_dir=experimental_read_from_local_dir, @@ -350,7 +350,7 @@ def _aux_create_a_mix(self, mix = make( mix_path, n_busbar=n_busbar, - allow_shedding=allow_shedding, + allow_detachment=allow_detachment, _add_to_name=_add_to_name, _compat_glop_version=_compat_glop_version, test=_test, diff --git a/grid2op/MakeEnv/Make.py b/grid2op/MakeEnv/Make.py index cedefc8f..a38cf2ef 100644 --- a/grid2op/MakeEnv/Make.py +++ b/grid2op/MakeEnv/Make.py @@ -20,7 +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_SHEDDING, DEFAULT_N_BUSBAR_PER_SUB +from grid2op.Space import DEFAULT_ALLOW_DETACHMENT, DEFAULT_N_BUSBAR_PER_SUB _VAR_FORCE_TEST = "_GRID2OP_FORCE_TEST" @@ -249,7 +249,7 @@ def _aux_make_multimix( test=False, experimental_read_from_local_dir=False, n_busbar=DEFAULT_N_BUSBAR_PER_SUB, - allow_shedding=DEFAULT_ALLOW_SHEDDING, + allow_detachmentnt=DEFAULT_ALLOW_DETACHMENT, _add_to_name="", _compat_glop_version=None, _overload_name_multimix=None, @@ -264,7 +264,7 @@ def _aux_make_multimix( dataset_path, experimental_read_from_local_dir=experimental_read_from_local_dir, n_busbar=n_busbar, - allow_shedding=allow_shedding, + allow_detachmentnallow_detachmentment, _test=test, _add_to_name=_add_to_name, _compat_glop_version=_compat_glop_version, @@ -289,7 +289,7 @@ def make( logger: Optional[logging.Logger]=None, experimental_read_from_local_dir : bool=False, n_busbar=DEFAULT_N_BUSBAR_PER_SUB, - allow_shedding=DEFAULT_ALLOW_SHEDDING, + allow_detachment=DEFAULT_ALLOW_DETACHMENT, _add_to_name : str="", _compat_glop_version : Optional[str]=None, _overload_name_multimix : Optional[str]=None, # do not use ! @@ -310,7 +310,7 @@ def make( The `n_busbar` parameters .. versionadded:: 1.11.0 - The `allow_shedding` parameter + The `allow_detachmentnt` parameter Parameters ---------- @@ -336,7 +336,7 @@ def make( n_busbar: ``int`` Number of independant busbars allowed per substations. By default it's 2. - allow_shedding: ``bool`` + allow_detachmentnt: ``bool`` Whether to allow loads and generators to be shed without a game over. By default it's False. kwargs: @@ -447,7 +447,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_shedding=allow_shedding, + allow_detachmentnt=allow_detachment, **kwargs ) @@ -494,7 +494,7 @@ def make_from_path_fn_(*args, **kwargs): dataset_path=ds_path, logger=logger, n_busbar=n_busbar, - allow_shedding=allow_shedding, + allow_detachmentnt=allow_detachment, _add_to_name=_add_to_name, _compat_glop_version=_compat_glop_version, experimental_read_from_local_dir=experimental_read_from_local_dir, @@ -510,7 +510,7 @@ def make_from_path_fn_(*args, **kwargs): real_ds_path, logger=logger, n_busbar=n_busbar, - allow_shedding=allow_shedding, + allow_detachmentnt=allow_detachment, experimental_read_from_local_dir=experimental_read_from_local_dir, _overload_name_multimix=_overload_name_multimix, **kwargs @@ -531,7 +531,7 @@ def make_from_path_fn_(*args, **kwargs): dataset_path=real_ds_path, logger=logger, n_busbar=n_busbar, - allow_shedding=allow_shedding, + allow_detachmentnt=allow_detachment, experimental_read_from_local_dir=experimental_read_from_local_dir, _overload_name_multimix=_overload_name_multimix, **kwargs diff --git a/grid2op/MakeEnv/MakeFromPath.py b/grid2op/MakeEnv/MakeFromPath.py index 4ca56397..feb19c93 100644 --- a/grid2op/MakeEnv/MakeFromPath.py +++ b/grid2op/MakeEnv/MakeFromPath.py @@ -33,7 +33,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_SHEDDING +from grid2op.Space import DEFAULT_N_BUSBAR_PER_SUB, DEFAULT_ALLOW_DETACHMENT from grid2op.MakeEnv.get_default_aux import _get_default_aux from grid2op.MakeEnv.PathUtils import _aux_fix_backend_internal_classes @@ -128,7 +128,7 @@ def make_from_dataset_path( logger=None, experimental_read_from_local_dir=False, n_busbar=DEFAULT_N_BUSBAR_PER_SUB, - allow_shedding=DEFAULT_ALLOW_SHEDDING, + allow_detachment=DEFAULT_ALLOW_DETACHMENT, _add_to_name="", _compat_glop_version=None, _overload_name_multimix=None, @@ -168,7 +168,7 @@ def make_from_dataset_path( n_busbar: ``int`` Number of independant busbars allowed per substations. By default it's 2. - allow_shedding; ``bool`` + allow_detachment; ``bool`` action_class: ``type``, optional Type of BaseAction the BaseAgent will be able to perform. @@ -955,7 +955,7 @@ def make_from_dataset_path( 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) - allow_shedding=allow_shedding, + allow_detachment=allow_detachment, _compat_glop_version=_compat_glop_version, _read_from_local_dir=None, # first environment to generate the classes and save them _local_dir_cls=None, @@ -1036,7 +1036,7 @@ def make_from_dataset_path( 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) - allow_shedding=allow_shedding, + allow_detachment=allow_detachment, _compat_glop_version=_compat_glop_version, _read_from_local_dir=classes_path, _allow_loaded_backend=allow_loaded_backend, diff --git a/grid2op/Space/GridObjects.py b/grid2op/Space/GridObjects.py index a31f8b68..32608346 100644 --- a/grid2op/Space/GridObjects.py +++ b/grid2op/Space/GridObjects.py @@ -33,7 +33,7 @@ # TODO tests of these methods and this class in general DEFAULT_N_BUSBAR_PER_SUB = 2 -DEFAULT_ALLOW_SHEDDING = False +DEFAULT_ALLOW_DETACHMENT = False class GridObjects: @@ -514,7 +514,7 @@ class GridObjects: sub_info : ClassVar[np.ndarray] = None dim_topo : ClassVar[np.ndarray] = -1 - allow_shedding : ClassVar[bool] = DEFAULT_ALLOW_SHEDDING + allow_detachment : ClassVar[bool] = DEFAULT_ALLOW_DETACHMENT # to which substation is connected each element load_to_subid : ClassVar[np.ndarray] = None @@ -687,7 +687,7 @@ def _clear_class_attribute(cls) -> None: """ cls.shunts_data_available = False cls.n_busbar_per_sub = DEFAULT_N_BUSBAR_PER_SUB - cls.allow_shedding = DEFAULT_ALLOW_SHEDDING + cls.allow_detachment = DEFAULT_ALLOW_DETACHMENT # for redispatching / unit commitment cls._li_attr_disp = [ @@ -2336,7 +2336,7 @@ def assert_grid_correct_cls(cls): cls._check_validity_alert_data() # shedding - assert isinstance(cls.allow_shedding, bool) + assert isinstance(cls.allow_detachment, bool) @classmethod def _check_validity_alarm_data(cls): @@ -3095,7 +3095,7 @@ def process_grid2op_compat(cls): if glop_ver < version.parse("1.11.0.dev0"): # Shedding did not exist, default value should have # no effect - cls.allow_shedding = DEFAULT_ALLOW_SHEDDING + cls.allow_detachment = DEFAULT_ALLOW_DETACHMENT res = True if res: @@ -4412,10 +4412,10 @@ class res(GridObjects): cls.alertable_line_ids = [] # Shedding - if 'allow_shedding' in dict_: - cls.allow_shedding = bool(dict_["allow_shedding"]) + if 'allow_detachment' in dict_: + cls.allow_detachment = bool(dict_["allow_detachment"]) else: # Compatibility for older versions - cls.allow_shedding = DEFAULT_ALLOW_SHEDDING + cls.allow_detachment = DEFAULT_ALLOW_DETACHMENT # save the representation of this class as dict tmp = {} @@ -5064,7 +5064,7 @@ class {cls.__name__}({cls._INIT_GRID_CLS.__name__}): alertable_line_ids = {alertable_line_ids_str} # shedding - allow_shedding = {cls.allow_shedding} + allow_detachment = {cls.allow_detachment} """ return res diff --git a/grid2op/Space/__init__.py b/grid2op/Space/__init__.py index 3cc3d895..56486e8c 100644 --- a/grid2op/Space/__init__.py +++ b/grid2op/Space/__init__.py @@ -1,5 +1,5 @@ -__all__ = ["RandomObject", "SerializableSpace", "GridObjects", "DEFAULT_N_BUSBAR_PER_SUB", "DEFAULT_ALLOW_SHEDDING"] +__all__ = ["RandomObject", "SerializableSpace", "GridObjects", "DEFAULT_N_BUSBAR_PER_SUB", "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, DEFAULT_ALLOW_SHEDDING +from grid2op.Space.GridObjects import GridObjects, DEFAULT_N_BUSBAR_PER_SUB, DEFAULT_ALLOW_DETACHMENT diff --git a/grid2op/tests/test_shedding.py b/grid2op/tests/test_shedding.py index f79f1252..7575eeef 100644 --- a/grid2op/tests/test_shedding.py +++ b/grid2op/tests/test_shedding.py @@ -12,7 +12,7 @@ def setUp(self) -> None: with warnings.catch_warnings(): warnings.filterwarnings("ignore") self.env = grid2op.make("rte_case5_example", param=p, - allow_shedding=True, test=True) + 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)} @@ -22,7 +22,7 @@ def tearDown(self) -> None: def test_shedding_parameter_is_true(self): assert hasattr(self.env, "allow_shedding") - assert self.env.allow_shedding is True + assert self.env.allow_detachment is True def test_shed_single_load(self): # Check that a single load can be shed From 54ef7196f3c484dd7e7ced8a5bf94c76c2a26122 Mon Sep 17 00:00:00 2001 From: Xavier Weiss Date: Wed, 6 Nov 2024 16:14:24 +0100 Subject: [PATCH 10/39] Refact: Remove Set_Shedding Signed-off-by: Xavier Weiss --- ...> 13_DetachmentOfLoadsAndGenerators.ipynb} | 2 +- grid2op/Backend/backend.py | 20 +++++++++---------- grid2op/Environment/baseEnv.py | 6 +++--- grid2op/Environment/environment.py | 11 ++++------ grid2op/MakeEnv/Make.py | 12 +++++------ 5 files changed, 24 insertions(+), 27 deletions(-) rename getting_started/{13_Shedding.ipynb => 13_DetachmentOfLoadsAndGenerators.ipynb} (99%) diff --git a/getting_started/13_Shedding.ipynb b/getting_started/13_DetachmentOfLoadsAndGenerators.ipynb similarity index 99% rename from getting_started/13_Shedding.ipynb rename to getting_started/13_DetachmentOfLoadsAndGenerators.ipynb index ea56ff16..c5522a1d 100644 --- a/getting_started/13_Shedding.ipynb +++ b/getting_started/13_DetachmentOfLoadsAndGenerators.ipynb @@ -10,7 +10,7 @@ "By default shedding is disabled in all environments, to provide the keyword argument allow_detachment when initializing the environment." ] }, - {allow_detachment + { "cell_type": "code", "execution_count": null, "metadata": {}, diff --git a/grid2op/Backend/backend.py b/grid2op/Backend/backend.py index f448a7e2..d843edb8 100644 --- a/grid2op/Backend/backend.py +++ b/grid2op/Backend/backend.py @@ -303,16 +303,6 @@ def cannot_handle_detachment(self): f"not possible with a backend of type {type(self)}.") self.allow_detachment = DEFAULT_ALLOW_DETACHMENT - def set_shedding(self, allow_detachment:bool=False): - """ - Override if the Backend supports shedding. - """ - - if allow_detachment: - raise BackendError("Backend does not support shedding") - else: - self.allow_detachment = allow_detachment - def make_complete_path(self, path : Union[os.PathLike, str], filename : Optional[Union[os.PathLike, str]]=None) -> str: @@ -1089,7 +1079,17 @@ def _runpf_with_diverging_exception(self, is_dc : bool) -> Optional[Exception]: """ conv = False exc_me = None + + # if not self.allow_detachment and (~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]}" + # ) try: + # load_p, load_q, load_v = self.loads_info() + # prod_p, prod_q, prod_v = self.generators_info() conv, exc_me = self.runpf(is_dc=is_dc) # run powerflow except Grid2OpException as exc_: exc_me = exc_ diff --git a/grid2op/Environment/baseEnv.py b/grid2op/Environment/baseEnv.py index ea3de461..954e88ef 100644 --- a/grid2op/Environment/baseEnv.py +++ b/grid2op/Environment/baseEnv.py @@ -334,7 +334,7 @@ def __init__( highres_sim_counter=None, update_obs_after_reward=False, n_busbar=2, - allow_detachmentnt:bool=False, + allow_detachment:bool=False, _is_test: bool = False, # TODO not implemented !! _init_obs: Optional[BaseObservation] =None, _local_dir_cls=None, @@ -361,7 +361,7 @@ def __init__( self._raw_backend_class = _raw_backend_class self._n_busbar = n_busbar # env attribute not class attribute ! - self.allow_detachment = allow_detachmentnt + self.allow_detachment = allow_detachment if other_rewards is None: other_rewards = {} @@ -659,7 +659,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_detachmentnt = self.allow_detachment + 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) diff --git a/grid2op/Environment/environment.py b/grid2op/Environment/environment.py index a39926b5..26358815 100644 --- a/grid2op/Environment/environment.py +++ b/grid2op/Environment/environment.py @@ -86,7 +86,7 @@ def __init__( parameters, name="unknown", n_busbar : N_BUSBAR_PER_SUB_TYPING=DEFAULT_N_BUSBAR_PER_SUB, - allow_detachmentnt:bool=DEFAULT_ALLOW_DETACHMENT, + allow_detachment:bool=DEFAULT_ALLOW_DETACHMENT, names_chronics_to_backend=None, actionClass=TopologyAction, observationClass=CompleteObservation, @@ -157,7 +157,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_detachmentnallow_detachmentment, + 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, @@ -303,9 +303,6 @@ def _init_backend( f'not be able to use the renderer, plot the grid etc. The error was "{exc_}"' ) - # Shedding - self.backend.set_shedding(self.allow_detachment) - # alarm set up self.load_alarm_data() self.load_alert_data() @@ -2212,7 +2209,7 @@ def init_obj_from_kwargs(cls, _local_dir_cls, _overload_name_multimix, n_busbar=DEFAULT_N_BUSBAR_PER_SUB, - allow_detachmentnt=DEFAULT_ALLOW_DETACHMENT, + allow_detachment=DEFAULT_ALLOW_DETACHMENT, ): res = cls(init_env_path=init_env_path, init_grid_path=init_grid_path, @@ -2245,7 +2242,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_detachmentnt=booallow_detachmentment), + allow_detachment=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/MakeEnv/Make.py b/grid2op/MakeEnv/Make.py index a38cf2ef..555354b1 100644 --- a/grid2op/MakeEnv/Make.py +++ b/grid2op/MakeEnv/Make.py @@ -249,7 +249,7 @@ def _aux_make_multimix( test=False, experimental_read_from_local_dir=False, n_busbar=DEFAULT_N_BUSBAR_PER_SUB, - allow_detachmentnt=DEFAULT_ALLOW_DETACHMENT, + allow_detachment=DEFAULT_ALLOW_DETACHMENT, _add_to_name="", _compat_glop_version=None, _overload_name_multimix=None, @@ -264,7 +264,7 @@ def _aux_make_multimix( dataset_path, experimental_read_from_local_dir=experimental_read_from_local_dir, n_busbar=n_busbar, - allow_detachmentnallow_detachmentment, + allow_detachment=allow_detachment, _test=test, _add_to_name=_add_to_name, _compat_glop_version=_compat_glop_version, @@ -447,7 +447,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_detachmentnt=allow_detachment, + allow_detachment=allow_detachment, **kwargs ) @@ -494,7 +494,7 @@ def make_from_path_fn_(*args, **kwargs): dataset_path=ds_path, logger=logger, n_busbar=n_busbar, - allow_detachmentnt=allow_detachment, + allow_detachment=allow_detachment, _add_to_name=_add_to_name, _compat_glop_version=_compat_glop_version, experimental_read_from_local_dir=experimental_read_from_local_dir, @@ -510,7 +510,7 @@ def make_from_path_fn_(*args, **kwargs): real_ds_path, logger=logger, n_busbar=n_busbar, - allow_detachmentnt=allow_detachment, + allow_detachment=allow_detachment, experimental_read_from_local_dir=experimental_read_from_local_dir, _overload_name_multimix=_overload_name_multimix, **kwargs @@ -531,7 +531,7 @@ def make_from_path_fn_(*args, **kwargs): dataset_path=real_ds_path, logger=logger, n_busbar=n_busbar, - allow_detachmentnt=allow_detachment, + allow_detachment=allow_detachment, experimental_read_from_local_dir=experimental_read_from_local_dir, _overload_name_multimix=_overload_name_multimix, **kwargs From d9da4bffdc5629d112ecf151a7cd0d75f35652fa Mon Sep 17 00:00:00 2001 From: Xavier Weiss Date: Wed, 6 Nov 2024 16:26:54 +0100 Subject: [PATCH 11/39] Add: Backend-Agnostic Detachment Check Signed-off-by: Xavier Weiss --- .../13_DetachmentOfLoadsAndGenerators.ipynb | 32 +++++++++++++++++-- grid2op/Backend/backend.py | 23 +++++++------ grid2op/Backend/pandaPowerBackend.py | 1 + 3 files changed, 45 insertions(+), 11 deletions(-) diff --git a/getting_started/13_DetachmentOfLoadsAndGenerators.ipynb b/getting_started/13_DetachmentOfLoadsAndGenerators.ipynb index c5522a1d..1d01ce17 100644 --- a/getting_started/13_DetachmentOfLoadsAndGenerators.ipynb +++ b/getting_started/13_DetachmentOfLoadsAndGenerators.ipynb @@ -4,8 +4,8 @@ "cell_type": "markdown", "metadata": {}, "source": [ - "### Shedding\n", - "In emergency conditions, it may be possible / necessary for a grid operator to disconnect certain loads, generators, or other components in order to prevent a larger blackout. This notebook explores how this can be achieved in Grid2OP. \n", + "### 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 shedding is disabled in all environments, to provide the keyword argument allow_detachment when initializing the environment." ] @@ -71,6 +71,34 @@ ")\n", "display(network.res_line.loc[:, [\"p_from_mw\", \"p_to_mw\", \"q_from_mvar\", \"q_to_mvar\"]])" ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "env.backend.loads_info()" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "env.backend.generators_info()" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "topo_vect = env.backend.get_topo_vect()\n", + "topo_vect[env.backend.load_pos_topo_vect]" + ] } ], "metadata": { diff --git a/grid2op/Backend/backend.py b/grid2op/Backend/backend.py index d843edb8..87d76738 100644 --- a/grid2op/Backend/backend.py +++ b/grid2op/Backend/backend.py @@ -1080,16 +1080,21 @@ def _runpf_with_diverging_exception(self, is_dc : bool) -> Optional[Exception]: conv = False exc_me = None - # if not self.allow_detachment and (~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]}" - # ) try: - # load_p, load_q, load_v = self.loads_info() - # prod_p, prod_q, prod_v = self.generators_info() + # 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[self.load_pos_topo_vect] + if not self.allow_detachment and (load_buses == -1).any(): + raise Grid2OpException(f"One or more loads were detached before powerflow in Backend {type(self).__name__}" + "but this is not allowed or not supported (Game Over)") + + gen_buses = topo_vect[self.gen_pos_topo_vect] + if not self.allow_detachment and (gen_buses == -1).any(): + raise Grid2OpException(f"One or more generators were detached before powerflow in Backend {type(self).__name__}" + "but this is not allowed or not supported (Game Over)") + conv, exc_me = self.runpf(is_dc=is_dc) # run powerflow except Grid2OpException as exc_: exc_me = exc_ diff --git a/grid2op/Backend/pandaPowerBackend.py b/grid2op/Backend/pandaPowerBackend.py index 82477f25..9bf85f27 100644 --- a/grid2op/Backend/pandaPowerBackend.py +++ b/grid2op/Backend/pandaPowerBackend.py @@ -344,6 +344,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(): From e66fd034df0427ac7b8db9b77a628ef017b0d4a3 Mon Sep 17 00:00:00 2001 From: Xavier Weiss Date: Wed, 6 Nov 2024 16:38:02 +0100 Subject: [PATCH 12/39] Add: ClassAttribute integration for AllowDetachment Signed-off-by: Xavier Weiss --- grid2op/Space/GridObjects.py | 15 +++++---------- 1 file changed, 5 insertions(+), 10 deletions(-) diff --git a/grid2op/Space/GridObjects.py b/grid2op/Space/GridObjects.py index 32608346..bc82dc72 100644 --- a/grid2op/Space/GridObjects.py +++ b/grid2op/Space/GridObjects.py @@ -2034,17 +2034,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}") @@ -3931,6 +3923,8 @@ def _make_cls_dict(cls, res, as_list=True, copy_=True, _topo_vect_only=False): res["name_shunt"] = None res["shunt_to_subid"] = None + + save_to_dict(res, cls, "allow_detachment", bool, copy_) if not _topo_vect_only: # all the attributes bellow are not needed for the "first call" @@ -4411,7 +4405,7 @@ class res(GridObjects): cls.alertable_line_names = [] cls.alertable_line_ids = [] - # Shedding + # Detachment of Loads / Generators if 'allow_detachment' in dict_: cls.allow_detachment = bool(dict_["allow_detachment"]) else: # Compatibility for older versions @@ -4976,7 +4970,8 @@ class {cls.__name__}({cls._INIT_GRID_CLS.__name__}): sub_info = {sub_info_str} dim_topo = {cls.dim_topo} - + allow_detachment = {cls.allow_detachment} + # to which substation is connected each element load_to_subid = {load_to_subid_str} gen_to_subid = {gen_to_subid_str} From 6aa67c08f625e4755cc46fb2ffb62b5776f67508 Mon Sep 17 00:00:00 2001 From: Xavier Weiss Date: Fri, 8 Nov 2024 10:55:40 +0100 Subject: [PATCH 13/39] Debug: Use _get_topo_vect inside Backend check for Detachment Signed-off-by: Xavier Weiss --- grid2op/Action/baseAction.py | 2 +- grid2op/Backend/backend.py | 20 +++++----- grid2op/Backend/educPandaPowerBackend.py | 2 - grid2op/Backend/pandaPowerBackend.py | 23 +---------- grid2op/Converter/BackendConverter.py | 4 +- grid2op/Environment/baseEnv.py | 11 +++--- grid2op/Environment/environment.py | 6 ++- grid2op/Environment/maskedEnvironment.py | 8 ++-- grid2op/Environment/timedOutEnv.py | 8 ++-- grid2op/MakeEnv/Make.py | 2 +- grid2op/MakeEnv/MakeFromPath.py | 1 + grid2op/Runner/runner.py | 6 ++- grid2op/Space/GridObjects.py | 49 ++++++++++++++---------- grid2op/tests/test_Action.py | 2 + grid2op/tests/test_shedding.py | 2 +- 15 files changed, 73 insertions(+), 73 deletions(-) diff --git a/grid2op/Action/baseAction.py b/grid2op/Action/baseAction.py index 3e2a2901..4246c166 100644 --- a/grid2op/Action/baseAction.py +++ b/grid2op/Action/baseAction.py @@ -845,7 +845,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) diff --git a/grid2op/Backend/backend.py b/grid2op/Backend/backend.py index 87d76738..2b21d61b 100644 --- a/grid2op/Backend/backend.py +++ b/grid2op/Backend/backend.py @@ -123,7 +123,7 @@ class Backend(GridObjects, ABC): ERR_INIT_POWERFLOW : str = "Power cannot be computed on the first time step, please check your data." def __init__(self, detailed_infos_for_cascading_failures:bool=False, - can_be_copied:bool=True, allow_detachment:bool=DEFAULT_ALLOW_DETACHMENT, + can_be_copied:bool=True, **kwargs): """ Initialize an instance of Backend. This does nothing per se. Only the call to :func:`Backend.load_grid` @@ -182,7 +182,7 @@ def __init__(self, # .. versionadded: 1.11.0 self._missing_detachment_support:bool = True - self.allow_detachment:bool = DEFAULT_ALLOW_DETACHMENT + self._allow_detachment:bool = DEFAULT_ALLOW_DETACHMENT def can_handle_more_than_2_busbar(self): """ @@ -271,7 +271,7 @@ def can_handle_detachment(self): At least, at time of writing there is no good reason to do so. """ self._missing_detachment_support = False - self.allow_detachment = type(self).allow_detachment + self._allow_detachment = type(self)._allow_detachment def cannot_handle_detachment(self): """ @@ -298,10 +298,10 @@ def cannot_handle_detachment(self): At least, at time of writing there is no good reason to do so. """ self._missing_detachment_support = False - if type(self.allow_detachment != DEFAULT_ALLOW_DETACHMENT): + if type(self._allow_detachment != 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.allow_detachment = DEFAULT_ALLOW_DETACHMENT + self._allow_detachment = DEFAULT_ALLOW_DETACHMENT def make_complete_path(self, path : Union[os.PathLike, str], @@ -1083,15 +1083,15 @@ def _runpf_with_diverging_exception(self, is_dc : bool) -> Optional[Exception]: try: # 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() + topo_vect = self._get_topo_vect() load_buses = topo_vect[self.load_pos_topo_vect] - if not self.allow_detachment and (load_buses == -1).any(): + if not self._allow_detachment and (load_buses == -1).any(): raise Grid2OpException(f"One or more loads were detached before powerflow in Backend {type(self).__name__}" "but this is not allowed or not supported (Game Over)") gen_buses = topo_vect[self.gen_pos_topo_vect] - if not self.allow_detachment and (gen_buses == -1).any(): + if not self._allow_detachment and (gen_buses == -1).any(): raise Grid2OpException(f"One or more generators were detached before powerflow in Backend {type(self).__name__}" "but this is not allowed or not supported (Game Over)") @@ -2173,10 +2173,10 @@ def assert_grid_correct(self, _local_dir_cls=None) -> None: "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 = False - self.allow_detachment = DEFAULT_ALLOW_DETACHMENT + self._allow_detachment = DEFAULT_ALLOW_DETACHMENT else: self._missing_detachment_support = False - self.allow_detachment = DEFAULT_ALLOW_DETACHMENT + self._allow_detachment = DEFAULT_ALLOW_DETACHMENT warnings.warn("Your backend is missing the `_missing_detachment_support` " "attribute.") diff --git a/grid2op/Backend/educPandaPowerBackend.py b/grid2op/Backend/educPandaPowerBackend.py index 57e6afad..a56d6600 100644 --- a/grid2op/Backend/educPandaPowerBackend.py +++ b/grid2op/Backend/educPandaPowerBackend.py @@ -67,7 +67,6 @@ class EducPandaPowerBackend(Backend): def __init__(self, detailed_infos_for_cascading_failures : Optional[bool]=False, - allow_detachment:bool=False, can_be_copied : Optional[bool]=True): """ Nothing much to do here except initializing what you would need (a tensorflow session, link to some @@ -84,7 +83,6 @@ def __init__(self, self, detailed_infos_for_cascading_failures=detailed_infos_for_cascading_failures, can_be_copied=can_be_copied, - allow_detachment=allow_detachment, # extra arguments that might be needed for building such a backend # these extra kwargs will be stored (without copy) in the # base class and used when another backend will be created diff --git a/grid2op/Backend/pandaPowerBackend.py b/grid2op/Backend/pandaPowerBackend.py index 9bf85f27..c3b048fd 100644 --- a/grid2op/Backend/pandaPowerBackend.py +++ b/grid2op/Backend/pandaPowerBackend.py @@ -1017,28 +1017,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 not self.allow_detachment and (~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 not self.allow_detachment and (~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") diff --git a/grid2op/Converter/BackendConverter.py b/grid2op/Converter/BackendConverter.py index f00f9953..84bed15c 100644 --- a/grid2op/Converter/BackendConverter.py +++ b/grid2op/Converter/BackendConverter.py @@ -95,7 +95,7 @@ def __init__( use_target_backend_name=False, kwargs_target_backend=None, kwargs_source_backend=None, - allow_detachmentnt:bool=False, + allow_detachment:bool=False, ): Backend.__init__( self, @@ -103,7 +103,7 @@ def __init__( use_target_backend_name=use_target_backend_name, kwargs_target_backend=kwargs_target_backend, kwargs_source_backend=kwargs_source_backend, - allow_detachment=allow_detachmentnt, + allow_detachment=allow_detachment, ) difcf = detailed_infos_for_cascading_failures if kwargs_source_backend is None: diff --git a/grid2op/Environment/baseEnv.py b/grid2op/Environment/baseEnv.py index 954e88ef..fc5ebdb0 100644 --- a/grid2op/Environment/baseEnv.py +++ b/grid2op/Environment/baseEnv.py @@ -28,7 +28,8 @@ HighResSimCounter) from grid2op.Backend import Backend from grid2op.dtypes import dt_int, dt_float, dt_bool -from grid2op.Space import GridObjects, RandomObject +from grid2op.Space import GridObjects, RandomObject, DEFAULT_ALLOW_DETACHMENT, DEFAULT_N_BUSBAR_PER_SUB +from grid2op.typing_variables import N_BUSBAR_PER_SUB_TYPING from grid2op.Exceptions import (Grid2OpException, EnvError, InvalidRedispatching, @@ -333,8 +334,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, - allow_detachment:bool=False, + 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, @@ -361,7 +362,7 @@ def __init__( self._raw_backend_class = _raw_backend_class self._n_busbar = n_busbar # env attribute not class attribute ! - self.allow_detachment = allow_detachment + self._allow_detachment = allow_detachment if other_rewards is None: other_rewards = {} @@ -659,7 +660,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._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) diff --git a/grid2op/Environment/environment.py b/grid2op/Environment/environment.py index 26358815..39fc4482 100644 --- a/grid2op/Environment/environment.py +++ b/grid2op/Environment/environment.py @@ -85,7 +85,7 @@ def __init__( 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, @@ -1495,6 +1495,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: @@ -2147,6 +2148,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 @@ -2242,7 +2244,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=allow_detachment, + 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 e3c55a7d..ba7c1ee8 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 numpy as np import os @@ -14,7 +14,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 @@ -163,7 +163,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, @@ -195,6 +196,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/timedOutEnv.py b/grid2op/Environment/timedOutEnv.py index a1952f99..377d55c6 100644 --- a/grid2op/Environment/timedOutEnv.py +++ b/grid2op/Environment/timedOutEnv.py @@ -5,7 +5,7 @@ # you can obtain one at http://mozilla.org/MPL/2.0/. # SPDX-License-Identifier: MPL-2.0 # This file is part of Grid2Op, Grid2Op a testbed platform to model sequential decision making in power systems. - +import warnings import time from math import floor from typing import Any, Dict, Tuple, Union, List, Literal @@ -15,7 +15,7 @@ from grid2op.Action import BaseAction from grid2op.Observation import BaseObservation from grid2op.Exceptions import EnvError -from grid2op.Space import DEFAULT_N_BUSBAR_PER_SUB +from grid2op.Space import DEFAULT_N_BUSBAR_PER_SUB, DEFAULT_ALLOW_DETACHMENT from grid2op.MakeEnv.PathUtils import USE_CLASS_IN_FILE @@ -232,7 +232,8 @@ def init_obj_from_kwargs(cls, _read_from_local_dir, _local_dir_cls, _overload_name_multimix, - n_busbar=DEFAULT_N_BUSBAR_PER_SUB): + n_busbar=DEFAULT_N_BUSBAR_PER_SUB, + allow_detachment=DEFAULT_ALLOW_DETACHMENT): grid2op_env={"init_env_path": init_env_path, "init_grid_path": init_grid_path, "chronics_handler": chronics_handler, @@ -266,6 +267,7 @@ def init_obj_from_kwargs(cls, "_raw_backend_class": _raw_backend_class, "_read_from_local_dir": _read_from_local_dir, "n_busbar": int(n_busbar), + "allow_detachment": bool(allow_detachment), "_local_dir_cls": _local_dir_cls, "_overload_name_multimix": _overload_name_multimix} if not "time_out_ms" in other_env_kwargs: diff --git a/grid2op/MakeEnv/Make.py b/grid2op/MakeEnv/Make.py index 555354b1..ec332d32 100644 --- a/grid2op/MakeEnv/Make.py +++ b/grid2op/MakeEnv/Make.py @@ -310,7 +310,7 @@ def make( The `n_busbar` parameters .. versionadded:: 1.11.0 - The `allow_detachmentnt` parameter + The `allow_detachment` parameter Parameters ---------- diff --git a/grid2op/MakeEnv/MakeFromPath.py b/grid2op/MakeEnv/MakeFromPath.py index feb19c93..5eaa30d8 100644 --- a/grid2op/MakeEnv/MakeFromPath.py +++ b/grid2op/MakeEnv/MakeFromPath.py @@ -169,6 +169,7 @@ def make_from_dataset_path( 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. diff --git a/grid2op/Runner/runner.py b/grid2op/Runner/runner.py index a16a66b6..9ee0e28c 100644 --- a/grid2op/Runner/runner.py +++ b/grid2op/Runner/runner.py @@ -29,7 +29,7 @@ from grid2op.dtypes import dt_float from grid2op.Opponent import BaseOpponent, NeverAttackBudget from grid2op.operator_attention import LinearAttentionBudget -from grid2op.Space import DEFAULT_N_BUSBAR_PER_SUB +from grid2op.Space import DEFAULT_N_BUSBAR_PER_SUB, DEFAULT_ALLOW_DETACHMENT from grid2op.Episode import EpisodeData # on windows if i start using sequential, i need to continue using sequential # if i start using parallel i need to continue using parallel @@ -354,6 +354,7 @@ def __init__( init_grid_path: str, path_chron, # path where chronics of injections are stored n_busbar=DEFAULT_N_BUSBAR_PER_SUB, + allow_detachment=DEFAULT_ALLOW_DETACHMENT, name_env="unknown", parameters_path=None, names_chronics_to_backend=None, @@ -466,6 +467,7 @@ def __init__( # TOOD doc for the attention budget """ self._n_busbar = n_busbar + self._allow_detachment = allow_detachment self.with_forecast = with_forecast self.name_env = name_env self._overload_name_multimix = _overload_name_multimix @@ -765,6 +767,7 @@ def _new_env(self, parameters) -> Tuple[BaseEnv, BaseAgent]: res = self.envClass.init_obj_from_kwargs( other_env_kwargs=self.other_env_kwargs, n_busbar=self._n_busbar, + allow_detachment=self._allow_detachment, init_env_path=self.init_env_path, init_grid_path=self.init_grid_path, chronics_handler=chronics_handler, @@ -1301,6 +1304,7 @@ def _get_params(self): "_overload_name_multimix": self._overload_name_multimix, "other_env_kwargs": self.other_env_kwargs, "n_busbar": self._n_busbar, + "allow_detachment": self._allow_detachment, "mp_context": None, # this is used in multi processing context, avoid to multi process a multi process stuff "_local_dir_cls": self._local_dir_cls, } diff --git a/grid2op/Space/GridObjects.py b/grid2op/Space/GridObjects.py index bc82dc72..dbe3f9f5 100644 --- a/grid2op/Space/GridObjects.py +++ b/grid2op/Space/GridObjects.py @@ -514,7 +514,7 @@ class GridObjects: sub_info : ClassVar[np.ndarray] = None dim_topo : ClassVar[np.ndarray] = -1 - allow_detachment : ClassVar[bool] = DEFAULT_ALLOW_DETACHMENT + _allow_detachment : ClassVar[bool] = DEFAULT_ALLOW_DETACHMENT # to which substation is connected each element load_to_subid : ClassVar[np.ndarray] = None @@ -687,7 +687,7 @@ def _clear_class_attribute(cls) -> None: """ cls.shunts_data_available = False cls.n_busbar_per_sub = DEFAULT_N_BUSBAR_PER_SUB - cls.allow_detachment = DEFAULT_ALLOW_DETACHMENT + cls._allow_detachment = DEFAULT_ALLOW_DETACHMENT # for redispatching / unit commitment cls._li_attr_disp = [ @@ -2326,9 +2326,8 @@ def assert_grid_correct_cls(cls): # alert data cls._check_validity_alert_data() - - # shedding - assert isinstance(cls.allow_detachment, bool) + + assert isinstance(cls._allow_detachment, bool) @classmethod def _check_validity_alarm_data(cls): @@ -2966,6 +2965,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._allow_detachment != 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: @@ -3085,9 +3087,9 @@ def process_grid2op_compat(cls): res = True if glop_ver < version.parse("1.11.0.dev0"): - # Shedding did not exist, default value should have + # Detachment did not exist, default value should have # no effect - cls.allow_detachment = DEFAULT_ALLOW_DETACHMENT + cls._allow_detachment = DEFAULT_ALLOW_DETACHMENT res = True if res: @@ -3740,6 +3742,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, "_allow_detachment", str, copy_) if not _topo_vect_only: # all the attributes bellow are not needed for the "first call" @@ -3922,9 +3926,6 @@ def _make_cls_dict(cls, res, as_list=True, copy_=True, _topo_vect_only=False): else: res["name_shunt"] = None res["shunt_to_subid"] = None - - - save_to_dict(res, cls, "allow_detachment", bool, copy_) if not _topo_vect_only: # all the attributes bellow are not needed for the "first call" @@ -4108,7 +4109,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. @@ -4122,7 +4123,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["_allow_detachment"] = cls._allow_detachment + # avoid further computation and save it if not as_list and not _topo_vect_only: cls._CLS_DICT_EXTENDED = res.copy() @@ -4195,6 +4198,18 @@ class res(GridObjects): cls._PATH_GRID_CLASSES = None else: cls._PATH_GRID_CLASSES = None + + # Detachment of Loads / Generators + if '_allow_detachment' in dict_: + if dict_["_allow_detachment"] == "True": + cls._allow_detachment = True + elif dict_["_allow_detachment"] == "False": + cls._allow_detachment = False + else: + raise ValueError(f"'allow_detachment' (value: {dict_["_allow_detachment"]}'')" + + "could not be converted to Boolean ") + else: # Compatibility for older versions + cls._allow_detachment = DEFAULT_ALLOW_DETACHMENT if 'n_busbar_per_sub' in dict_: cls.n_busbar_per_sub = int(dict_["n_busbar_per_sub"]) @@ -4405,12 +4420,6 @@ class res(GridObjects): cls.alertable_line_names = [] cls.alertable_line_ids = [] - # Detachment of Loads / Generators - if 'allow_detachment' in dict_: - cls.allow_detachment = bool(dict_["allow_detachment"]) - else: # Compatibility for older versions - cls.allow_detachment = DEFAULT_ALLOW_DETACHMENT - # save the representation of this class as dict tmp = {} cls._make_cls_dict_extended(cls, tmp, as_list=False, copy_=True) @@ -4970,7 +4979,7 @@ class {cls.__name__}({cls._INIT_GRID_CLS.__name__}): sub_info = {sub_info_str} dim_topo = {cls.dim_topo} - allow_detachment = {cls.allow_detachment} + _allow_detachment = {cls._allow_detachment} # to which substation is connected each element load_to_subid = {load_to_subid_str} @@ -5059,7 +5068,7 @@ class {cls.__name__}({cls._INIT_GRID_CLS.__name__}): alertable_line_ids = {alertable_line_ids_str} # shedding - allow_detachment = {cls.allow_detachment} + _allow_detachment = {cls._allow_detachment} """ return res diff --git a/grid2op/tests/test_Action.py b/grid2op/tests/test_Action.py index 059686f0..c8866db1 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", + "_allow_detachment": "False", "name_gen": ["gen_0", "gen_1", "gen_2", "gen_3", "gen_4"], "name_load": [ "load_0", diff --git a/grid2op/tests/test_shedding.py b/grid2op/tests/test_shedding.py index 7575eeef..760a8a60 100644 --- a/grid2op/tests/test_shedding.py +++ b/grid2op/tests/test_shedding.py @@ -22,7 +22,7 @@ def tearDown(self) -> None: def test_shedding_parameter_is_true(self): assert hasattr(self.env, "allow_shedding") - assert self.env.allow_detachment is True + assert self.env._allow_detachment is True def test_shed_single_load(self): # Check that a single load can be shed From aebfaa13198ec95ca24e1d3fa437b461fc340172 Mon Sep 17 00:00:00 2001 From: Xavier Weiss Date: Fri, 8 Nov 2024 11:11:33 +0100 Subject: [PATCH 14/39] Test: Update Backend Tests to expect _run_pf_with_diverging to catch detachments Signed-off-by: Xavier Weiss --- grid2op/tests/aaa_test_backend_interface.py | 74 +++++++++------------ grid2op/tests/test_alert_gym_compat.py | 1 + 2 files changed, 31 insertions(+), 44 deletions(-) diff --git a/grid2op/tests/aaa_test_backend_interface.py b/grid2op/tests/aaa_test_backend_interface.py index b45bd379..a3290a4c 100644 --- a/grid2op/tests/aaa_test_backend_interface.py +++ b/grid2op/tests/aaa_test_backend_interface.py @@ -814,7 +814,7 @@ 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 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)}" @@ -829,7 +829,7 @@ 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=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 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)}" @@ -862,7 +862,7 @@ def test_17_isolated_gen_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 gen." + # 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)}" @@ -877,7 +877,7 @@ def test_17_isolated_gen_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=True) - assert not res[0], "It is expected (at time of writing) that your backend returns `False` in case of isolated gen." + # 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)}" @@ -916,7 +916,7 @@ def test_18_isolated_shunt_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 shunt." + # 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)}" @@ -931,7 +931,7 @@ def test_18_isolated_shunt_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=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 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)}" @@ -967,7 +967,7 @@ def test_19_isolated_storage_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 storage units in AC." + # 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)}" @@ -981,7 +981,7 @@ def test_19_isolated_storage_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=True) - assert not res[0], "It is expected (at time of writing) that your backend returns `False` in case of isolated storage unit." + # 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)}" @@ -989,7 +989,8 @@ def test_19_isolated_storage_stops_computation(self): 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) + """ + Tests that a disconnected load unit will be caught by the `_runpf_with_diverging_exception` method. This test supposes that : @@ -1003,7 +1004,7 @@ def test_20_disconnected_load_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. + Behaviour changed in version 1.11.0 (no longer caught by runpf() itelf) """ self.skip_if_needed() backend = self.aux_make_backend() @@ -1014,13 +1015,9 @@ 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") + error = backend._runpf_with_diverging_exception(is_dc=False) + assert error is not None + assert isinstance(error, Grid2OpException) backend.reset(self.get_path(), self.get_casefile()) # a load alone on a bus @@ -1029,16 +1026,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") - + error = backend._runpf_with_diverging_exception(is_dc=True) + assert error is not None + assert isinstance(error, Grid2OpException) + 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) + """ + Tests that a disconnected generator will be caught by the `_runpf_with_diverging_exception` method This test supposes that : @@ -1052,7 +1046,7 @@ def test_21_disconnected_gen_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. + Behaviour changed in version 1.11.0 (no longer caught by runpf() itelf) """ self.skip_if_needed() backend = self.aux_make_backend() @@ -1063,13 +1057,9 @@ 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") + error = backend._runpf_with_diverging_exception(is_dc=False) + assert error is not None + assert isinstance(error, Grid2OpException) backend.reset(self.get_path(), self.get_casefile()) # a disconnected generator @@ -1078,14 +1068,10 @@ 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") - + res = backend._runpf_with_diverging_exception(is_dc=True) + assert error is not None + assert isinstance(error, Grid2OpException) + 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) @@ -1117,7 +1103,7 @@ def test_22_islanded_grid_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], f"It is expected that your backend return `(False, _)` in case of non connected grid in AC." + # assert not res[0], f"It is expected that your backend return `(False, _)` in case of non connected grid in AC." 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): @@ -1133,7 +1119,7 @@ def test_22_islanded_grid_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=True) - assert not res[0], f"It is expected that your backend return `(False, _)` in case of non connected grid in DC." + # assert not res[0], f"It is expected that your backend return `(False, _)` in case of non connected grid in DC." 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): diff --git a/grid2op/tests/test_alert_gym_compat.py b/grid2op/tests/test_alert_gym_compat.py index e522deee..04895e37 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, " From 486cffa74bcb6eafa2f823c336e93a2705f6e4a9 Mon Sep 17 00:00:00 2001 From: Xavier Weiss Date: Fri, 8 Nov 2024 11:46:10 +0100 Subject: [PATCH 15/39] Debug: Try to fix Test_Issue_125 Signed-off-by: Xavier Weiss --- grid2op/Backend/backend.py | 6 ++++-- grid2op/Converter/BackendConverter.py | 5 ++--- grid2op/tests/helper_path_test.py | 4 +++- 3 files changed, 9 insertions(+), 6 deletions(-) diff --git a/grid2op/Backend/backend.py b/grid2op/Backend/backend.py index 2b21d61b..b38becc1 100644 --- a/grid2op/Backend/backend.py +++ b/grid2op/Backend/backend.py @@ -1083,8 +1083,10 @@ def _runpf_with_diverging_exception(self, is_dc : bool) -> Optional[Exception]: try: # 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() - + if hasattr(self, "_get_topo_vect"): + topo_vect = self._get_topo_vect() + else: + topo_vect = self.get_topo_vect() load_buses = topo_vect[self.load_pos_topo_vect] if not self._allow_detachment and (load_buses == -1).any(): raise Grid2OpException(f"One or more loads were detached before powerflow in Backend {type(self).__name__}" diff --git a/grid2op/Converter/BackendConverter.py b/grid2op/Converter/BackendConverter.py index 84bed15c..38178908 100644 --- a/grid2op/Converter/BackendConverter.py +++ b/grid2op/Converter/BackendConverter.py @@ -95,7 +95,6 @@ def __init__( use_target_backend_name=False, kwargs_target_backend=None, kwargs_source_backend=None, - allow_detachment:bool=False, ): Backend.__init__( self, @@ -103,7 +102,6 @@ def __init__( use_target_backend_name=use_target_backend_name, kwargs_target_backend=kwargs_target_backend, kwargs_source_backend=kwargs_source_backend, - allow_detachment=allow_detachment, ) difcf = detailed_infos_for_cascading_failures if kwargs_source_backend is None: @@ -167,10 +165,11 @@ 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, DEFAULT_ALLOW_DETACHMENT + from grid2op.Space import DEFAULT_N_BUSBAR_PER_SUB 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) 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/tests/helper_path_test.py b/grid2op/tests/helper_path_test.py index cd72b9ef..39d8439a 100644 --- a/grid2op/tests/helper_path_test.py +++ b/grid2op/tests/helper_path_test.py @@ -67,12 +67,14 @@ 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)._allow_detachment = allow_detachment return bk def get_path(self) -> str: From 938709a96175fbe2e1c74dda6abbff2d8cffd13a Mon Sep 17 00:00:00 2001 From: Xavier Weiss Date: Sat, 23 Nov 2024 20:53:29 +0100 Subject: [PATCH 16/39] Add: 'detachment_is_allowed' in GridObj and Environment Signed-off-by: Xavier Weiss --- grid2op/Backend/backend.py | 16 ++++++------- grid2op/Converter/BackendConverter.py | 4 +++- grid2op/Environment/environment.py | 1 + grid2op/Space/GridObjects.py | 34 +++++++++++++++------------ grid2op/tests/test_shedding.py | 2 +- 5 files changed, 32 insertions(+), 25 deletions(-) diff --git a/grid2op/Backend/backend.py b/grid2op/Backend/backend.py index b38becc1..41364ca1 100644 --- a/grid2op/Backend/backend.py +++ b/grid2op/Backend/backend.py @@ -182,7 +182,7 @@ def __init__(self, # .. versionadded: 1.11.0 self._missing_detachment_support:bool = True - self._allow_detachment:bool = DEFAULT_ALLOW_DETACHMENT + self.detachment_is_allowed:bool = DEFAULT_ALLOW_DETACHMENT def can_handle_more_than_2_busbar(self): """ @@ -271,7 +271,7 @@ def can_handle_detachment(self): At least, at time of writing there is no good reason to do so. """ self._missing_detachment_support = False - self._allow_detachment = type(self)._allow_detachment + self.detachment_is_allowed = type(self).detachment_is_allowed def cannot_handle_detachment(self): """ @@ -298,10 +298,10 @@ def cannot_handle_detachment(self): At least, at time of writing there is no good reason to do so. """ self._missing_detachment_support = False - if type(self._allow_detachment != DEFAULT_ALLOW_DETACHMENT): + 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._allow_detachment = DEFAULT_ALLOW_DETACHMENT + self.detachment_is_allowed = DEFAULT_ALLOW_DETACHMENT def make_complete_path(self, path : Union[os.PathLike, str], @@ -1088,12 +1088,12 @@ def _runpf_with_diverging_exception(self, is_dc : bool) -> Optional[Exception]: else: topo_vect = self.get_topo_vect() load_buses = topo_vect[self.load_pos_topo_vect] - if not self._allow_detachment and (load_buses == -1).any(): + if not self.detachment_is_allowed and (load_buses == -1).any(): raise Grid2OpException(f"One or more loads were detached before powerflow in Backend {type(self).__name__}" "but this is not allowed or not supported (Game Over)") gen_buses = topo_vect[self.gen_pos_topo_vect] - if not self._allow_detachment and (gen_buses == -1).any(): + if not self.detachment_is_allowed and (gen_buses == -1).any(): raise Grid2OpException(f"One or more generators were detached before powerflow in Backend {type(self).__name__}" "but this is not allowed or not supported (Game Over)") @@ -2175,10 +2175,10 @@ def assert_grid_correct(self, _local_dir_cls=None) -> None: "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 = False - self._allow_detachment = DEFAULT_ALLOW_DETACHMENT + self.detachment_is_allowed = DEFAULT_ALLOW_DETACHMENT else: self._missing_detachment_support = False - self._allow_detachment = DEFAULT_ALLOW_DETACHMENT + self.detachment_is_allowed = DEFAULT_ALLOW_DETACHMENT warnings.warn("Your backend is missing the `_missing_detachment_support` " "attribute.") diff --git a/grid2op/Converter/BackendConverter.py b/grid2op/Converter/BackendConverter.py index 38178908..19e62c2a 100644 --- a/grid2op/Converter/BackendConverter.py +++ b/grid2op/Converter/BackendConverter.py @@ -165,9 +165,11 @@ 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() diff --git a/grid2op/Environment/environment.py b/grid2op/Environment/environment.py index 39fc4482..e29a7238 100644 --- a/grid2op/Environment/environment.py +++ b/grid2op/Environment/environment.py @@ -280,6 +280,7 @@ 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 diff --git a/grid2op/Space/GridObjects.py b/grid2op/Space/GridObjects.py index dbe3f9f5..55382a1e 100644 --- a/grid2op/Space/GridObjects.py +++ b/grid2op/Space/GridObjects.py @@ -514,7 +514,7 @@ class GridObjects: sub_info : ClassVar[np.ndarray] = None dim_topo : ClassVar[np.ndarray] = -1 - _allow_detachment : ClassVar[bool] = DEFAULT_ALLOW_DETACHMENT + detachment_is_allowed : ClassVar[bool] = DEFAULT_ALLOW_DETACHMENT # to which substation is connected each element load_to_subid : ClassVar[np.ndarray] = None @@ -643,6 +643,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: @@ -687,7 +691,7 @@ def _clear_class_attribute(cls) -> None: """ cls.shunts_data_available = False cls.n_busbar_per_sub = DEFAULT_N_BUSBAR_PER_SUB - cls._allow_detachment = DEFAULT_ALLOW_DETACHMENT + cls.detachment_is_allowed = DEFAULT_ALLOW_DETACHMENT # for redispatching / unit commitment cls._li_attr_disp = [ @@ -2966,7 +2970,7 @@ def init_grid(cls, gridobj, force=False, extra_name=None, force_module=None, _lo # different `n_busbar_per_sub` name_res += f"_{gridobj.n_busbar_per_sub}" - if gridobj._allow_detachment != DEFAULT_ALLOW_DETACHMENT: + 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: @@ -3089,7 +3093,7 @@ def process_grid2op_compat(cls): if glop_ver < version.parse("1.11.0.dev0"): # Detachment did not exist, default value should have # no effect - cls._allow_detachment = DEFAULT_ALLOW_DETACHMENT + cls.detachment_is_allowed = DEFAULT_ALLOW_DETACHMENT res = True if res: @@ -3743,7 +3747,7 @@ def _make_cls_dict(cls, res, as_list=True, copy_=True, _topo_vect_only=False): res[k] = v return - save_to_dict(res, cls, "_allow_detachment", str, copy_) + 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" @@ -4124,7 +4128,7 @@ 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["_allow_detachment"] = cls._allow_detachment + res["detachment_is_allowed"] = cls.detachment_is_allowed # avoid further computation and save it if not as_list and not _topo_vect_only: @@ -4200,16 +4204,16 @@ class res(GridObjects): cls._PATH_GRID_CLASSES = None # Detachment of Loads / Generators - if '_allow_detachment' in dict_: - if dict_["_allow_detachment"] == "True": - cls._allow_detachment = True - elif dict_["_allow_detachment"] == "False": - cls._allow_detachment = False + 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"'allow_detachment' (value: {dict_["_allow_detachment"]}'')" + - "could not be converted to Boolean ") + raise ValueError(f"'detachment_is_allowed' (value: {dict_["detachment_is_allowed"]}'')" + + "could not be converted to Boolean ") else: # Compatibility for older versions - cls._allow_detachment = DEFAULT_ALLOW_DETACHMENT + 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"]) @@ -4979,7 +4983,7 @@ class {cls.__name__}({cls._INIT_GRID_CLS.__name__}): sub_info = {sub_info_str} dim_topo = {cls.dim_topo} - _allow_detachment = {cls._allow_detachment} + detachment_is_allowed = {cls.detachment_is_allowed} # to which substation is connected each element load_to_subid = {load_to_subid_str} diff --git a/grid2op/tests/test_shedding.py b/grid2op/tests/test_shedding.py index 760a8a60..b59eef0e 100644 --- a/grid2op/tests/test_shedding.py +++ b/grid2op/tests/test_shedding.py @@ -21,8 +21,8 @@ def tearDown(self) -> None: self.env.close() def test_shedding_parameter_is_true(self): - assert hasattr(self.env, "allow_shedding") 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 From 4112dfeb0e0db2dd3c9f13efadae1e149f6f1c6a Mon Sep 17 00:00:00 2001 From: Xavier Weiss Date: Sat, 23 Nov 2024 20:56:22 +0100 Subject: [PATCH 17/39] Debug: assert 'detachment_is_allowed' in GridObject Signed-off-by: Xavier Weiss --- grid2op/Space/GridObjects.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/grid2op/Space/GridObjects.py b/grid2op/Space/GridObjects.py index 55382a1e..145eaba3 100644 --- a/grid2op/Space/GridObjects.py +++ b/grid2op/Space/GridObjects.py @@ -2331,7 +2331,7 @@ def assert_grid_correct_cls(cls): # alert data cls._check_validity_alert_data() - assert isinstance(cls._allow_detachment, bool) + assert isinstance(cls.detachment_is_allowed, bool) @classmethod def _check_validity_alarm_data(cls): From 7b6dabc7c0e641a453ab482ade39851d0abbfaf3 Mon Sep 17 00:00:00 2001 From: Xavier Weiss Date: Sun, 24 Nov 2024 09:31:13 +0100 Subject: [PATCH 18/39] Test: Update strings for detachment Signed-off-by: Xavier Weiss --- grid2op/tests/test_Action.py | 2 +- grid2op/tests/test_Observation.py | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/grid2op/tests/test_Action.py b/grid2op/tests/test_Action.py index c8866db1..a852b3ed 100644 --- a/grid2op/tests/test_Action.py +++ b/grid2op/tests/test_Action.py @@ -108,7 +108,7 @@ def _get_action_grid_class(): json_ = { "glop_version": grid2op.__version__, "n_busbar_per_sub": "2", - "_allow_detachment": "False", + "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 dff0b205..cb621595 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", From 0666b398c4e60b04a2974864fa69161807e5c0d5 Mon Sep 17 00:00:00 2001 From: Xavier Weiss Date: Sun, 24 Nov 2024 09:36:33 +0100 Subject: [PATCH 19/39] Debug: Fix nested f-string in GridObjects Signed-off-by: Xavier Weiss --- grid2op/Space/GridObjects.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/grid2op/Space/GridObjects.py b/grid2op/Space/GridObjects.py index 145eaba3..4737c802 100644 --- a/grid2op/Space/GridObjects.py +++ b/grid2op/Space/GridObjects.py @@ -4210,7 +4210,7 @@ class res(GridObjects): elif dict_["detachment_is_allowed"] == "False": cls.detachment_is_allowed = False else: - raise ValueError(f"'detachment_is_allowed' (value: {dict_["detachment_is_allowed"]}'')" + + 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 From 7cdf6c2a0d4a5902222bec3ed75a8804599c372e Mon Sep 17 00:00:00 2001 From: Xavier Weiss Date: Sun, 24 Nov 2024 10:56:56 +0100 Subject: [PATCH 20/39] Debug: Add post-powerflow check for load / gen disconnect Signed-off-by: Xavier Weiss --- grid2op/Backend/backend.py | 12 ++++++++++++ grid2op/Space/GridObjects.py | 2 +- 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/grid2op/Backend/backend.py b/grid2op/Backend/backend.py index 41364ca1..78c5aabb 100644 --- a/grid2op/Backend/backend.py +++ b/grid2op/Backend/backend.py @@ -1087,12 +1087,15 @@ def _runpf_with_diverging_exception(self, is_dc : bool) -> Optional[Exception]: topo_vect = self._get_topo_vect() else: topo_vect = self.get_topo_vect() + load_buses = topo_vect[self.load_pos_topo_vect] + if not self.detachment_is_allowed and (load_buses == -1).any(): raise Grid2OpException(f"One or more loads were detached before powerflow in Backend {type(self).__name__}" "but this is not allowed or not supported (Game Over)") gen_buses = topo_vect[self.gen_pos_topo_vect] + if not self.detachment_is_allowed and (gen_buses == -1).any(): raise Grid2OpException(f"One or more generators were detached before powerflow in Backend {type(self).__name__}" "but this is not allowed or not supported (Game Over)") @@ -1106,6 +1109,15 @@ def _runpf_with_diverging_exception(self, is_dc : bool) -> Optional[Exception]: "GAME OVER: Powerflow has diverged during computation " "or a load has been disconnected or a generator has been disconnected." ) + + # Post-Powerflow Check + if not self.detachment_is_allowed: + resulting_act = self.get_action_to_set() + load_buses_act_set = resulting_act._set_topo_vect[self.load_pos_topo_vect] + gen_buses_act_set = resulting_act._set_topo_vect[self.gen_pos_topo_vect] + if (load_buses_act_set == -1).any() or (gen_buses_act_set == -1).any(): + exc_me = Grid2OpException(f"One or more generators or loads were detached in Backend {type(self).__name__}" + " as a result of a Grid2Op action, but this is not allowed or not supported (Game Over)") return exc_me def next_grid_state(self, diff --git a/grid2op/Space/GridObjects.py b/grid2op/Space/GridObjects.py index 4737c802..37b36685 100644 --- a/grid2op/Space/GridObjects.py +++ b/grid2op/Space/GridObjects.py @@ -5072,7 +5072,7 @@ class {cls.__name__}({cls._INIT_GRID_CLS.__name__}): alertable_line_ids = {alertable_line_ids_str} # shedding - _allow_detachment = {cls._allow_detachment} + detachment_is_allowed = {cls.detachment_is_allowed} """ return res From bb231937b0c7689490c34f45492733660d8c7703 Mon Sep 17 00:00:00 2001 From: Xavier Weiss Date: Sun, 24 Nov 2024 11:04:42 +0100 Subject: [PATCH 21/39] Debug: Add convergence check to post-powerflow check Signed-off-by: Xavier Weiss --- grid2op/Backend/backend.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/grid2op/Backend/backend.py b/grid2op/Backend/backend.py index 78c5aabb..73735ae7 100644 --- a/grid2op/Backend/backend.py +++ b/grid2op/Backend/backend.py @@ -1111,7 +1111,7 @@ def _runpf_with_diverging_exception(self, is_dc : bool) -> Optional[Exception]: ) # Post-Powerflow Check - if not self.detachment_is_allowed: + if not self.detachment_is_allowed and conv: resulting_act = self.get_action_to_set() load_buses_act_set = resulting_act._set_topo_vect[self.load_pos_topo_vect] gen_buses_act_set = resulting_act._set_topo_vect[self.gen_pos_topo_vect] From 6e082120d4009661e78bd61e2d3a8216afd6424f Mon Sep 17 00:00:00 2001 From: DONNOT Benjamin Date: Mon, 25 Nov 2024 11:56:51 +0100 Subject: [PATCH 22/39] forbid args in env creation, fix bug on CI, refacto the 'make' call Signed-off-by: DONNOT Benjamin --- CHANGELOG.rst | 2 + grid2op/Environment/_forecast_env.py | 4 +- grid2op/Environment/_obsEnv.py | 14 +-- grid2op/Environment/baseEnv.py | 4 +- grid2op/Environment/environment.py | 3 +- grid2op/MakeEnv/MakeFromPath.py | 110 +++++++++--------------- grid2op/Observation/observationSpace.py | 1 + grid2op/tests/automatic_classes.py | 4 +- 8 files changed, 62 insertions(+), 80 deletions(-) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index e4a63b0f..e8c9cc6f 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. diff --git a/grid2op/Environment/_forecast_env.py b/grid2op/Environment/_forecast_env.py index ab4d7056..0f468e0b 100644 --- a/grid2op/Environment/_forecast_env.py +++ b/grid2op/Environment/_forecast_env.py @@ -19,10 +19,10 @@ class _ForecastEnv(Environment): It is used by obs.get_forecast_env. """ - def __init__(self, *args, **kwargs): + def __init__(self,**kwargs): if "_update_obs_after_reward" not in kwargs: kwargs["_update_obs_after_reward"] = False - super().__init__(*args, **kwargs) + super().__init__(**kwargs) self._do_not_erase_local_dir_cls = True def step(self, action: BaseAction) -> Tuple[BaseObservation, float, bool, STEP_INFO_TYPING]: diff --git a/grid2op/Environment/_obsEnv.py b/grid2op/Environment/_obsEnv.py index 4048cedb..c8b8ac79 100644 --- a/grid2op/Environment/_obsEnv.py +++ b/grid2op/Environment/_obsEnv.py @@ -18,6 +18,7 @@ from grid2op.Chronics import ChangeNothing from grid2op.Chronics._obs_fake_chronics_handler import _ObsCH from grid2op.Rules import RulesChecker +from grid2op.Space import DEFAULT_ALLOW_DETACHMENT from grid2op.operator_attention import LinearAttentionBudget from grid2op.Environment.baseEnv import BaseEnv @@ -41,6 +42,7 @@ class _ObsEnv(BaseEnv): def __init__( self, + *, # since 1.11.0 I force kwargs init_env_path, init_grid_path, backend_instanciated, @@ -63,16 +65,17 @@ def __init__( logger=None, highres_sim_counter=None, _complete_action_cls=None, + allow_detachment:bool=DEFAULT_ALLOW_DETACHMENT, _ptr_orig_obs_space=None, _local_dir_cls=None, # only set at the first call to `make(...)` after should be false _read_from_local_dir=None, ): BaseEnv.__init__( self, - init_env_path, - init_grid_path, - copy.deepcopy(parameters), - thermal_limit_a, + init_env_path=init_env_path, + init_grid_path=init_grid_path, + parameters=copy.deepcopy(parameters), + thermal_limit_a=thermal_limit_a, other_rewards=other_rewards, epsilon_poly=epsilon_poly, tol_poly=tol_poly, @@ -84,7 +87,8 @@ def __init__( highres_sim_counter=highres_sim_counter, update_obs_after_reward=False, _local_dir_cls=_local_dir_cls, - _read_from_local_dir=_read_from_local_dir + _read_from_local_dir=_read_from_local_dir, + allow_detachment=allow_detachment ) self._do_not_erase_local_dir_cls = True self.__unusable = False # unsuable if backend cannot be copied diff --git a/grid2op/Environment/baseEnv.py b/grid2op/Environment/baseEnv.py index b609485e..5d405b21 100644 --- a/grid2op/Environment/baseEnv.py +++ b/grid2op/Environment/baseEnv.py @@ -50,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 @@ -315,10 +316,11 @@ def foo(manager): def __init__( self, + *, # since 1.11.0 I force kwargs init_env_path: os.PathLike, init_grid_path: os.PathLike, parameters: Parameters, - voltagecontrolerClass: type, + voltagecontrolerClass: type=ControlVoltageFromFile, name="unknown", thermal_limit_a: Optional[np.ndarray] = None, epsilon_poly: float = 1e-4, # precision of the redispatching algorithm diff --git a/grid2op/Environment/environment.py b/grid2op/Environment/environment.py index 68e98de0..431ea224 100644 --- a/grid2op/Environment/environment.py +++ b/grid2op/Environment/environment.py @@ -79,6 +79,7 @@ class Environment(BaseEnv): def __init__( self, + *, # since 1.11.0 I force kwargs init_env_path: str, init_grid_path: str, chronics_handler, @@ -427,7 +428,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 diff --git a/grid2op/MakeEnv/MakeFromPath.py b/grid2op/MakeEnv/MakeFromPath.py index e4a96e61..f7febf50 100644 --- a/grid2op/MakeEnv/MakeFromPath.py +++ b/grid2op/MakeEnv/MakeFromPath.py @@ -915,7 +915,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: @@ -964,48 +1001,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_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, - allow_detachment=allow_detachment, ) 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 @@ -1044,8 +1047,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 @@ -1056,42 +1057,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) - allow_detachment=allow_detachment, - _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/observationSpace.py b/grid2op/Observation/observationSpace.py index 5b4a00d9..1bc0290a 100644 --- a/grid2op/Observation/observationSpace.py +++ b/grid2op/Observation/observationSpace.py @@ -202,6 +202,7 @@ def _create_obs_env(self, env, observationClass): _ptr_orig_obs_space=self, _local_dir_cls=env._local_dir_cls, _read_from_local_dir=env._read_from_local_dir, + allow_detachment=type(env.backend).detachment_is_allowed ) for k, v in self.obs_env.other_rewards.items(): v.initialize(self.obs_env) diff --git a/grid2op/tests/automatic_classes.py b/grid2op/tests/automatic_classes.py index c50b91c5..428e5c34 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(...) From 4cf73c66e1ee6fd9bbd41b211f4ddcbdf13eb458 Mon Sep 17 00:00:00 2001 From: DONNOT Benjamin Date: Mon, 25 Nov 2024 14:33:25 +0100 Subject: [PATCH 23/39] fixes to make tests pass Signed-off-by: DONNOT Benjamin --- grid2op/Backend/backend.py | 28 +++++++++++++--------------- grid2op/Space/GridObjects.py | 2 +- grid2op/tests/automatic_classes.py | 6 ++++++ 3 files changed, 20 insertions(+), 16 deletions(-) diff --git a/grid2op/Backend/backend.py b/grid2op/Backend/backend.py index 1aae3966..cd8f361e 100644 --- a/grid2op/Backend/backend.py +++ b/grid2op/Backend/backend.py @@ -1081,12 +1081,16 @@ def _runpf_with_diverging_exception(self, is_dc : bool) -> Optional[Exception]: exc_me = None 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 - if hasattr(self, "_get_topo_vect"): - topo_vect = self._get_topo_vect() - else: - topo_vect = self.get_topo_vect() + topo_vect = self.get_topo_vect() load_buses = topo_vect[self.load_pos_topo_vect] @@ -1099,8 +1103,6 @@ def _runpf_with_diverging_exception(self, is_dc : bool) -> Optional[Exception]: if not self.detachment_is_allowed and (gen_buses == -1).any(): raise Grid2OpException(f"One or more generators were detached before powerflow in Backend {type(self).__name__}" "but this is not allowed or not supported (Game Over)") - - conv, exc_me = self.runpf(is_dc=is_dc) # run powerflow except Grid2OpException as exc_: exc_me = exc_ @@ -1109,15 +1111,6 @@ def _runpf_with_diverging_exception(self, is_dc : bool) -> Optional[Exception]: "GAME OVER: Powerflow has diverged during computation " "or a load has been disconnected or a generator has been disconnected." ) - - # Post-Powerflow Check - if not self.detachment_is_allowed and conv: - resulting_act = self.get_action_to_set() - load_buses_act_set = resulting_act._set_topo_vect[self.load_pos_topo_vect] - gen_buses_act_set = resulting_act._set_topo_vect[self.gen_pos_topo_vect] - if (load_buses_act_set == -1).any() or (gen_buses_act_set == -1).any(): - exc_me = Grid2OpException(f"One or more generators or loads were detached in Backend {type(self).__name__}" - " as a result of a Grid2Op action, but this is not allowed or not supported (Game Over)") return exc_me def next_grid_state(self, @@ -2047,6 +2040,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, diff --git a/grid2op/Space/GridObjects.py b/grid2op/Space/GridObjects.py index ed8517f5..8739e547 100644 --- a/grid2op/Space/GridObjects.py +++ b/grid2op/Space/GridObjects.py @@ -4568,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 diff --git a/grid2op/tests/automatic_classes.py b/grid2op/tests/automatic_classes.py index 428e5c34..f0eb5d05 100644 --- a/grid2op/tests/automatic_classes.py +++ b/grid2op/tests/automatic_classes.py @@ -613,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() From dbba375f26a4b30300cdade5928fc63f465ba985 Mon Sep 17 00:00:00 2001 From: DONNOT Benjamin Date: Mon, 25 Nov 2024 16:00:12 +0100 Subject: [PATCH 24/39] fix issue 125 Signed-off-by: DONNOT Benjamin --- grid2op/Backend/backend.py | 10 ++-- grid2op/Backend/pandaPowerBackend.py | 50 +++++++++++-------- grid2op/tests/helper_path_test.py | 7 ++- .../test_PandaPowerBackendDefaultFunc.py | 7 ++- 4 files changed, 44 insertions(+), 30 deletions(-) diff --git a/grid2op/Backend/backend.py b/grid2op/Backend/backend.py index cd8f361e..5007fa2a 100644 --- a/grid2op/Backend/backend.py +++ b/grid2op/Backend/backend.py @@ -724,10 +724,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: """ @@ -1090,10 +1090,8 @@ def _runpf_with_diverging_exception(self, is_dc : bool) -> Optional[Exception]: # 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() - + topo_vect = self.get_topo_vect() load_buses = topo_vect[self.load_pos_topo_vect] - if not self.detachment_is_allowed and (load_buses == -1).any(): raise Grid2OpException(f"One or more loads were detached before powerflow in Backend {type(self).__name__}" "but this is not allowed or not supported (Game Over)") diff --git a/grid2op/Backend/pandaPowerBackend.py b/grid2op/Backend/pandaPowerBackend.py index bc02668d..33ce0806 100644 --- a/grid2op/Backend/pandaPowerBackend.py +++ b/grid2op/Backend/pandaPowerBackend.py @@ -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_topo_vect() + self.line_status[:] = self._get_line_status() self.comp_time = 0.0 def load_grid(self, @@ -717,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 = {} @@ -805,7 +808,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 @@ -824,7 +827,7 @@ 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._get_topo_vect() def _convert_id_topo(self, id_big_topo): """ @@ -1083,6 +1086,7 @@ def runpf(self, is_dc : bool=False) -> Tuple[bool, Union[Exception, None]]: in case of "do nothing" action applied. """ try: + self._get_topo_vect() # do that before any possible divergence 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) @@ -1128,7 +1132,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") @@ -1183,11 +1187,10 @@ def runpf(self, is_dc : bool=False) -> Tuple[bool, Union[Exception, None]]: 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 + self._get_topo_vect() # do that after (maybe useless) return True, None except pp.powerflow.LoadflowNotConverged as exc_: @@ -1222,6 +1225,10 @@ 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[:] = False def copy(self) -> "PandaPowerBackend": """ @@ -1383,8 +1390,10 @@ 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[id_] = False def _reconnect_line(self, id_): @@ -1399,29 +1408,30 @@ def get_topo_vect(self) -> np.ndarray: def _get_topo_vect(self): cls = type(self) - res = np.full(cls.dim_topo, fill_value=np.iinfo(dt_int).max, dtype=dt_int) - + # 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 + storage_status = 1 * self._grid.storage["in_service"].values + 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) diff --git a/grid2op/tests/helper_path_test.py b/grid2op/tests/helper_path_test.py index 39d8439a..e057aedd 100644 --- a/grid2op/tests/helper_path_test.py +++ b/grid2op/tests/helper_path_test.py @@ -67,8 +67,11 @@ 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, allow_detachment=False) -> 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() diff --git a/grid2op/tests/test_PandaPowerBackendDefaultFunc.py b/grid2op/tests/test_PandaPowerBackendDefaultFunc.py index 33a29011..3770e336 100644 --- a/grid2op/tests/test_PandaPowerBackendDefaultFunc.py +++ b/grid2op/tests/test_PandaPowerBackendDefaultFunc.py @@ -64,7 +64,8 @@ def get_topo_vect(self): """ otherwise there are some infinite recursions """ - res = np.full(self.dim_topo, fill_value=-1, dtype=dt_int) + self._topo_vect.flags.writeable = True + res = self._topo_vect line_status = np.concatenate( ( @@ -112,13 +113,14 @@ def get_topo_vect(self): for bus_id in self._grid.gen["bus"].values: res[self.gen_pos_topo_vect[i]] = 1 if bus_id == self.gen_to_subid[i] else 2 i += 1 - + res[self.gen_pos_topo_vect[~self._grid.gen["in_service"]]] = -1 i = 0 for bus_id in self._grid.load["bus"].values: res[self.load_pos_topo_vect[i]] = ( 1 if bus_id == self.load_to_subid[i] else 2 ) i += 1 + res[self.load_pos_topo_vect[~self._grid.load["in_service"]]] = -1 # do not forget storage units ! i = 0 @@ -127,6 +129,7 @@ def get_topo_vect(self): 1 if bus_id == self.storage_to_subid[i] else 2 ) i += 1 + self._topo_vect.flags.writeable = False return res From 09d1a650ea46d293ec4ef9a44895f9e248bed261 Mon Sep 17 00:00:00 2001 From: DONNOT Benjamin Date: Mon, 25 Nov 2024 17:47:56 +0100 Subject: [PATCH 25/39] still working on making tests pass with new more concise implementation Signed-off-by: DONNOT Benjamin --- CHANGELOG.rst | 4 +++- grid2op/Backend/pandaPowerBackend.py | 11 ++++------- grid2op/tests/aaa_test_backend_interface.py | 2 +- 3 files changed, 8 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index e8c9cc6f..28e7c3ce 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -140,7 +140,9 @@ 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. + [1.10.4] - 2024-10-15 ------------------------- - [FIXED] new pypi link (no change in code) diff --git a/grid2op/Backend/pandaPowerBackend.py b/grid2op/Backend/pandaPowerBackend.py index 33ce0806..ee6fbffe 100644 --- a/grid2op/Backend/pandaPowerBackend.py +++ b/grid2op/Backend/pandaPowerBackend.py @@ -909,11 +909,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__ @@ -939,6 +935,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( @@ -1190,7 +1188,6 @@ def runpf(self, is_dc : bool=False) -> Tuple[bool, Union[Exception, None]]: if not self._grid.converged: raise pp.powerflow.LoadflowNotConverged("Divergence without specific reason (self._grid.converged is False)") self.div_exception = None - self._get_topo_vect() # do that after (maybe useless) return True, None except pp.powerflow.LoadflowNotConverged as exc_: @@ -1427,7 +1424,7 @@ def _get_topo_vect(self): self._topo_vect[cls.gen_pos_topo_vect[~gen_status]] = -1 # storage if cls.n_storage: - storage_status = 1 * self._grid.storage["in_service"].values + storage_status = self._grid.storage["in_service"].values 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 diff --git a/grid2op/tests/aaa_test_backend_interface.py b/grid2op/tests/aaa_test_backend_interface.py index 1f8e8732..7b6872cc 100644 --- a/grid2op/tests/aaa_test_backend_interface.py +++ b/grid2op/tests/aaa_test_backend_interface.py @@ -1471,7 +1471,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]}" From d6bf9a8c291f80050c28a695f552ed468eb23226 Mon Sep 17 00:00:00 2001 From: DONNOT Benjamin Date: Tue, 26 Nov 2024 10:26:03 +0100 Subject: [PATCH 26/39] should fix all issues, need to make the AAA test suite better now Signed-off-by: DONNOT Benjamin --- grid2op/Backend/backend.py | 6 ++-- grid2op/Backend/pandaPowerBackend.py | 37 +++++++++++++++++---- grid2op/tests/aaa_test_backend_interface.py | 26 ++++++++++++--- grid2op/tests/helper_path_test.py | 2 +- 4 files changed, 56 insertions(+), 15 deletions(-) diff --git a/grid2op/Backend/backend.py b/grid2op/Backend/backend.py index 5007fa2a..9ba913fc 100644 --- a/grid2op/Backend/backend.py +++ b/grid2op/Backend/backend.py @@ -180,9 +180,9 @@ def __init__(self, #: 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:bool = True - self.detachment_is_allowed:bool = DEFAULT_ALLOW_DETACHMENT + #: .. versionadded: 1.11.0 + self._missing_detachment_support : bool = True + self.detachment_is_allowed : bool = DEFAULT_ALLOW_DETACHMENT def can_handle_more_than_2_busbar(self): """ diff --git a/grid2op/Backend/pandaPowerBackend.py b/grid2op/Backend/pandaPowerBackend.py index ee6fbffe..1adda34d 100644 --- a/grid2op/Backend/pandaPowerBackend.py +++ b/grid2op/Backend/pandaPowerBackend.py @@ -327,8 +327,8 @@ def reset(self, warnings.simplefilter("ignore", FutureWarning) self._grid = copy.deepcopy(self.__pp_backend_initial_grid) self._reset_all_nan() + self._get_line_status() self._get_topo_vect() - self.line_status[:] = self._get_line_status() self.comp_time = 0.0 def load_grid(self, @@ -771,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) @@ -782,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() @@ -1084,8 +1086,14 @@ def runpf(self, is_dc : bool=False) -> Tuple[bool, Union[Exception, None]]: in case of "do nothing" action applied. """ try: - self._get_topo_vect() # do that before any possible divergence + # 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(): @@ -1131,7 +1139,6 @@ 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") @@ -1225,8 +1232,9 @@ def _reset_all_nan(self) -> None: 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 @@ -1372,12 +1380,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 @@ -1391,19 +1402,33 @@ def _disconnect_line(self, id_): 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): + """ + .. danger:: + you should have called `self._get_line_status` before otherwise it might + not behave correctly ! + + Returns + ------- + _type_ + _description_ + """ cls = type(self) # lines / trafo diff --git a/grid2op/tests/aaa_test_backend_interface.py b/grid2op/tests/aaa_test_backend_interface.py index 7b6872cc..40409eb5 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: + 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)* diff --git a/grid2op/tests/helper_path_test.py b/grid2op/tests/helper_path_test.py index e057aedd..35083efa 100644 --- a/grid2op/tests/helper_path_test.py +++ b/grid2op/tests/helper_path_test.py @@ -77,7 +77,7 @@ def make_backend_with_glue_code(self, 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)._allow_detachment = allow_detachment + type(bk).set_detachment_is_allowed(allow_detachment) return bk def get_path(self) -> str: From 8b70407ce3e282ad365690895f6bddc0b02b299c Mon Sep 17 00:00:00 2001 From: DONNOT Benjamin Date: Tue, 26 Nov 2024 17:44:48 +0100 Subject: [PATCH 27/39] fixing broken tests (hopefully) and improve AAA backend test suite for new feature Signed-off-by: DONNOT Benjamin --- grid2op/Backend/backend.py | 46 ++-- grid2op/Backend/pandaPowerBackend.py | 17 +- grid2op/Environment/environment.py | 7 +- grid2op/tests/aaa_test_backend_interface.py | 285 +++++++++++++------- grid2op/tests/test_attached_envs_compat.py | 25 +- grid2op/tests/test_n_busbar_per_sub.py | 10 +- 6 files changed, 252 insertions(+), 138 deletions(-) diff --git a/grid2op/Backend/backend.py b/grid2op/Backend/backend.py index 9ba913fc..fd71dde5 100644 --- a/grid2op/Backend/backend.py +++ b/grid2op/Backend/backend.py @@ -121,6 +121,9 @@ 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, @@ -181,7 +184,7 @@ def __init__(self, self.n_busbar_per_sub: int = DEFAULT_N_BUSBAR_PER_SUB #: .. versionadded: 1.11.0 - self._missing_detachment_support : bool = True + self._missing_detachment_support_info : bool = True self.detachment_is_allowed : bool = DEFAULT_ALLOW_DETACHMENT def can_handle_more_than_2_busbar(self): @@ -270,7 +273,7 @@ def can_handle_detachment(self): 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 = False + self._missing_detachment_support_info = False self.detachment_is_allowed = type(self).detachment_is_allowed def cannot_handle_detachment(self): @@ -297,7 +300,7 @@ def cannot_handle_detachment(self): 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 = False + 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)}.") @@ -1079,7 +1082,7 @@ 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 @@ -1091,21 +1094,28 @@ def _runpf_with_diverging_exception(self, is_dc : bool) -> Optional[Exception]: # 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[self.load_pos_topo_vect] - if not self.detachment_is_allowed and (load_buses == -1).any(): - raise Grid2OpException(f"One or more loads were detached before powerflow in Backend {type(self).__name__}" - "but this is not allowed or not supported (Game Over)") + 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[self.gen_pos_topo_vect] + 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 not self.detachment_is_allowed and (gen_buses == -1).any(): - raise Grid2OpException(f"One or more generators were detached before powerflow in Backend {type(self).__name__}" - "but this is not allowed or not supported (Game Over)") + 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." ) @@ -2181,8 +2191,8 @@ def assert_grid_correct(self, _local_dir_cls=None) -> None: "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"): - if self._missing_detachment_support: + 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. " @@ -2194,12 +2204,12 @@ def assert_grid_correct(self, _local_dir_cls=None) -> None: "\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 = False + self._missing_detachment_support_info = False self.detachment_is_allowed = DEFAULT_ALLOW_DETACHMENT else: - self._missing_detachment_support = False + self._missing_detachment_support_info = False self.detachment_is_allowed = DEFAULT_ALLOW_DETACHMENT - warnings.warn("Your backend is missing the `_missing_detachment_support` " + warnings.warn("Your backend is missing the `_missing_detachment_support_info` " "attribute.") orig_type = type(self) diff --git a/grid2op/Backend/pandaPowerBackend.py b/grid2op/Backend/pandaPowerBackend.py index 1adda34d..533017b1 100644 --- a/grid2op/Backend/pandaPowerBackend.py +++ b/grid2op/Backend/pandaPowerBackend.py @@ -829,6 +829,9 @@ 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.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): @@ -899,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 @@ -1183,11 +1185,8 @@ 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 @@ -1336,7 +1335,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 = self._missing_detachment_support + res._missing_detachment_support_info = self._missing_detachment_support_info res.div_exception = self.div_exception return res @@ -1552,8 +1551,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" diff --git a/grid2op/Environment/environment.py b/grid2op/Environment/environment.py index 431ea224..299e0104 100644 --- a/grid2op/Environment/environment.py +++ b/grid2op/Environment/environment.py @@ -265,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() @@ -282,7 +282,6 @@ def _init_backend( 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 @@ -296,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( diff --git a/grid2op/tests/aaa_test_backend_interface.py b/grid2op/tests/aaa_test_backend_interface.py index 40409eb5..71cabe00 100644 --- a/grid2op/tests/aaa_test_backend_interface.py +++ b/grid2op/tests/aaa_test_backend_interface.py @@ -66,7 +66,7 @@ def test_00create_backend(self): "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: + 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.") @@ -803,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 : @@ -818,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 @@ -830,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() @@ -844,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 : @@ -866,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 @@ -877,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() @@ -892,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 : @@ -916,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") @@ -931,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() @@ -946,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 : @@ -969,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") @@ -982,29 +989,17 @@ 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): + 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. @@ -1020,10 +1015,11 @@ def test_20_disconnected_load_stops_computation(self): .. note:: Currently this stops the computation of the environment and lead to a game over. - Behaviour changed in version 1.11.0 (no longer caught by runpf() itelf) + .. 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() @@ -1031,9 +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 - error = backend._runpf_with_diverging_exception(is_dc=False) - assert error is not None - assert isinstance(error, Grid2OpException) + 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 @@ -1042,11 +1036,9 @@ 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 - error = backend._runpf_with_diverging_exception(is_dc=True) - assert error is not None - assert isinstance(error, Grid2OpException) + self._aux_test_detachment(backend, is_dc=True, detachment_should_pass=True) - def test_21_disconnected_gen_stops_computation(self): + 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 @@ -1062,10 +1054,11 @@ def test_21_disconnected_gen_stops_computation(self): .. note:: Currently this stops the computation of the environment and lead to a game over. - Behaviour changed in version 1.11.0 (no longer caught by runpf() itelf) + .. 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() @@ -1073,9 +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 - error = backend._runpf_with_diverging_exception(is_dc=False) - assert error is not None - assert isinstance(error, Grid2OpException) + self._aux_test_detachment(backend, is_dc=False, detachment_should_pass=True) backend.reset(self.get_path(), self.get_casefile()) # a disconnected generator @@ -1084,9 +1075,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_with_diverging_exception(is_dc=True) - assert error is not None - assert isinstance(error, Grid2OpException) + 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) @@ -1119,7 +1108,7 @@ def test_22_islanded_grid_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], f"It is expected that your backend return `(False, _)` in case of non connected grid in AC." + assert not res[0], f"It is expected that your backend return `(False, _)` in case of non connected grid in AC." 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): @@ -1135,7 +1124,7 @@ def test_22_islanded_grid_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=True) - # assert not res[0], f"It is expected that your backend return `(False, _)` in case of non connected grid in DC." + assert not res[0], f"It is expected that your backend return `(False, _)` in case of non connected grid in DC." 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): @@ -1278,7 +1267,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 @@ -1701,4 +1690,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_31_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/test_attached_envs_compat.py b/grid2op/tests/test_attached_envs_compat.py index 9b790497..6418b56e 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,17 @@ 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 + import pdb + pdb.set_trace() + 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 f1e59b0c..778e83c0 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}" From 2e813009f285825d273ecfb9c69d026ba049ff5b Mon Sep 17 00:00:00 2001 From: DONNOT Benjamin Date: Tue, 26 Nov 2024 18:14:48 +0100 Subject: [PATCH 28/39] remove a pdb... Signed-off-by: DONNOT Benjamin --- grid2op/tests/test_attached_envs_compat.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/grid2op/tests/test_attached_envs_compat.py b/grid2op/tests/test_attached_envs_compat.py index 6418b56e..7a40d86e 100644 --- a/grid2op/tests/test_attached_envs_compat.py +++ b/grid2op/tests/test_attached_envs_compat.py @@ -234,8 +234,6 @@ def test_action_space(self): def test_observation_space(self): assert issubclass(self.env.observation_space.subtype, CompleteObservation) - import pdb - pdb.set_trace() assert self.env.observation_space.n == 420, f"{self.env.observation_space.n}" def test_same_env_as_no_storage(self): From 3e25e952c3cd5bb11dfe6ab733973239fbc84ad7 Mon Sep 17 00:00:00 2001 From: Xavier Weiss Date: Wed, 27 Nov 2024 10:58:48 +0100 Subject: [PATCH 29/39] Debug: Fix check in PandaPower Backend that prevents large-scale load/gen detachment Signed-off-by: Xavier Weiss --- grid2op/Backend/pandaPowerBackend.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/grid2op/Backend/pandaPowerBackend.py b/grid2op/Backend/pandaPowerBackend.py index c3b048fd..4b41084d 100644 --- a/grid2op/Backend/pandaPowerBackend.py +++ b/grid2op/Backend/pandaPowerBackend.py @@ -1085,8 +1085,9 @@ def runpf(self, is_dc : bool=False) -> Tuple[bool, Union[Exception, None]]: self.load_theta[:], ) = self._loads_info() + load_in_service = self._grid.res_load["in_service"] if not is_dc: - if not np.isfinite(self.load_v).all(): + if not np.isfinite(self.load_v[load_in_service]).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]}") From d2d71ba33f261e3692ef17e8386cfec8c3c79ec4 Mon Sep 17 00:00:00 2001 From: Xavier Weiss Date: Wed, 27 Nov 2024 11:46:46 +0100 Subject: [PATCH 30/39] Debug: Use load dataframe instead of res_load for in_service check Signed-off-by: Xavier Weiss --- grid2op/Backend/pandaPowerBackend.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/grid2op/Backend/pandaPowerBackend.py b/grid2op/Backend/pandaPowerBackend.py index 4b41084d..d0db0e38 100644 --- a/grid2op/Backend/pandaPowerBackend.py +++ b/grid2op/Backend/pandaPowerBackend.py @@ -1085,7 +1085,7 @@ def runpf(self, is_dc : bool=False) -> Tuple[bool, Union[Exception, None]]: self.load_theta[:], ) = self._loads_info() - load_in_service = self._grid.res_load["in_service"] + load_in_service = self._grid.load["in_service"] if not is_dc: if not np.isfinite(self.load_v[load_in_service]).all(): # TODO see if there is a better way here From 809dfe4df2f067f429aa3c0db97ef5c6051ea0bf Mon Sep 17 00:00:00 2001 From: Xavier Weiss Date: Wed, 27 Nov 2024 13:30:58 +0100 Subject: [PATCH 31/39] Debug: Temporarily Rollback HotFix to PandaPowerBackend Signed-off-by: Xavier Weiss --- grid2op/Backend/pandaPowerBackend.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/grid2op/Backend/pandaPowerBackend.py b/grid2op/Backend/pandaPowerBackend.py index d0db0e38..c3b048fd 100644 --- a/grid2op/Backend/pandaPowerBackend.py +++ b/grid2op/Backend/pandaPowerBackend.py @@ -1085,9 +1085,8 @@ def runpf(self, is_dc : bool=False) -> Tuple[bool, Union[Exception, None]]: self.load_theta[:], ) = self._loads_info() - load_in_service = self._grid.load["in_service"] if not is_dc: - if not np.isfinite(self.load_v[load_in_service]).all(): + 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]}") From edcc81c0f1e62f466d80544d53fbe33f4f175a6d Mon Sep 17 00:00:00 2001 From: Xavier Weiss Date: Mon, 2 Dec 2024 10:30:57 +0100 Subject: [PATCH 32/39] Refact: Improve Code Smell Signed-off-by: Xavier Weiss --- .../13_DetachmentOfLoadsAndGenerators.ipynb | 72 ++++--------------- grid2op/Agent/topologyGreedy.py | 4 +- 2 files changed, 17 insertions(+), 59 deletions(-) diff --git a/getting_started/13_DetachmentOfLoadsAndGenerators.ipynb b/getting_started/13_DetachmentOfLoadsAndGenerators.ipynb index 1d01ce17..81e6a5d8 100644 --- a/getting_started/13_DetachmentOfLoadsAndGenerators.ipynb +++ b/getting_started/13_DetachmentOfLoadsAndGenerators.ipynb @@ -7,7 +7,7 @@ "### 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 shedding is disabled in all environments, to provide the keyword argument allow_detachment when initializing the environment." + "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." ] }, { @@ -16,21 +16,28 @@ "metadata": {}, "outputs": [], "source": [ - "import numpy as np\n", "import matplotlib.pyplot as plt\n", "import grid2op\n", - "from lightsim2grid import LightSimBackend\n", "from grid2op.Parameters import Parameters\n", - "\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}\")" + "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." ] }, { @@ -39,12 +46,10 @@ "metadata": {}, "outputs": [], "source": [ - "# Disconnect the load at substation 4\n", "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_4_2\"]], -1),\n", - " (env.load_pos_topo_vect[load_lookup[\"load_3_1\"]], -1)]})\n", - "# act = env.action_space({\"set_bus\":[(env.gen_pos_topo_vect[gen_lookup[\"gen_0_0\"]], -1)]})\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", @@ -52,58 +57,11 @@ "plotter.plot_obs(obs, figure=plt.figure(figsize=(8,5)))\n", "plt.show()" ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "import pandapower as pp\n", - "network = env.backend._grid.deepcopy()\n", - "display(network.res_line.loc[:, [\"p_from_mw\", \"p_to_mw\", \"q_from_mvar\", \"q_to_mvar\"]])\n", - "pp.runpp(network,\n", - " check_connectivity=False,\n", - " init=\"dc\",\n", - " lightsim2grid=False,\n", - " max_iteration=10,\n", - " distributed_slack=False,\n", - ")\n", - "display(network.res_line.loc[:, [\"p_from_mw\", \"p_to_mw\", \"q_from_mvar\", \"q_to_mvar\"]])" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "env.backend.loads_info()" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "env.backend.generators_info()" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "topo_vect = env.backend.get_topo_vect()\n", - "topo_vect[env.backend.load_pos_topo_vect]" - ] } ], "metadata": { "kernelspec": { - "display_name": "venv_grid2op", + "display_name": "venv_test", "language": "python", "name": "python3" }, diff --git a/grid2op/Agent/topologyGreedy.py b/grid2op/Agent/topologyGreedy.py index a6f84239..a37492ba 100644 --- a/grid2op/Agent/topologyGreedy.py +++ b/grid2op/Agent/topologyGreedy.py @@ -6,7 +6,7 @@ # SPDX-License-Identifier: MPL-2.0 # This file is part of Grid2Op, Grid2Op a testbed platform to model sequential decision making in power systems. -from typing import List +from typing import List, Optional from grid2op.Observation import BaseObservation from grid2op.Action import BaseAction, ActionSpace from grid2op.Agent.greedyAgent import GreedyAgent @@ -27,7 +27,7 @@ class TopologyGreedy(GreedyAgent): def __init__(self, action_space: ActionSpace, simulated_time_step : int =1): GreedyAgent.__init__(self, action_space, simulated_time_step=simulated_time_step) - self.tested_action : List[BaseAction]= None + self.tested_action : Optional[list[BaseAction]] = None def _get_tested_action(self, observation: BaseObservation) -> List[BaseAction]: if self.tested_action is None: From 5ee9aee5e5a9e136ce57fdcdbe271b8feeba0251 Mon Sep 17 00:00:00 2001 From: Xavier Weiss Date: Mon, 2 Dec 2024 11:12:53 +0100 Subject: [PATCH 33/39] Add: Bring back fix to PandaPowerBackend for isolated loads Signed-off-by: Xavier Weiss --- grid2op/Backend/pandaPowerBackend.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/grid2op/Backend/pandaPowerBackend.py b/grid2op/Backend/pandaPowerBackend.py index 533017b1..9fabd2d7 100644 --- a/grid2op/Backend/pandaPowerBackend.py +++ b/grid2op/Backend/pandaPowerBackend.py @@ -1116,8 +1116,9 @@ def runpf(self, is_dc : bool=False) -> Tuple[bool, Union[Exception, None]]: self.load_theta[:], ) = self._loads_info() + load_in_service = self._grid.load["in_service"] if not is_dc: - if not np.isfinite(self.load_v).all(): + if not np.isfinite(self.load_v[load_in_service]).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]}") From 9adc3a4d8e40cdce0cd95fd392f0e1972f8ce5e1 Mon Sep 17 00:00:00 2001 From: Xavier Weiss Date: Mon, 2 Dec 2024 15:57:01 +0100 Subject: [PATCH 34/39] Refact: Simplify _check_kirchhoff Signed-off-by: Xavier Weiss --- grid2op/Action/baseAction.py | 46 +++-- grid2op/Backend/backend.py | 366 ++++++++++++++++++++--------------- 2 files changed, 244 insertions(+), 168 deletions(-) diff --git a/grid2op/Action/baseAction.py b/grid2op/Action/baseAction.py index 2d321638..5cc700f3 100644 --- a/grid2op/Action/baseAction.py +++ b/grid2op/Action/baseAction.py @@ -2171,6 +2171,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 @@ -2381,23 +2409,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/Backend/backend.py b/grid2op/Backend/backend.py index fd71dde5..ed019e64 100644 --- a/grid2op/Backend/backend.py +++ b/grid2op/Backend/backend.py @@ -15,7 +15,9 @@ from abc import ABC, abstractmethod import numpy as np import pandas as pd +from dataclasses import dataclass from typing import Tuple, Optional, Any, Dict, Union + try: from typing import Self except ImportError: @@ -37,6 +39,34 @@ from grid2op.Space import GridObjects, DEFAULT_N_BUSBAR_PER_SUB, DEFAULT_ALLOW_DETACHMENT +@dataclass +class KirchhoffInfo: + p_or:np.ndarray + q_or:np.ndarray + v_or:np.ndarray + p_ex:np.ndarray + q_ex:np.ndarray + v_ex:np.ndarray + p_gen:np.ndarray + q_gen:np.ndarray + v_gen:np.ndarray + p_load:np.ndarray + q_load:np.ndarray + v_load:np.ndarray + p_subs:np.ndarray + q_subs:np.ndarray + p_bus:np.ndarray + q_bus:np.ndarray + v_bus:np.ndarray + topo_vect:np.ndarray + p_storage:Optional[np.ndarray] = None + q_storage:Optional[np.ndarray] = None + v_storage:Optional[np.ndarray] = None + p_s:Optional[np.ndarray] = None + q_s:Optional[np.ndarray] = None + v_s:Optional[np.ndarray] = None + bus_s:Optional[np.ndarray] = None + # TODO method to get V and theta at each bus, could be in the same shape as check_kirchoff @@ -1254,264 +1284,298 @@ 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 - - .. warning:: /!\\\\ Internal, do not use unless you know what you are doing /!\\\\ - - .. versionadded:: 1.11.0 - Fix the typo of the :attr:`Backend.check_kirchoff` function - - Check that the powergrid respects kirchhoff's law. - This function can be called at any moment (after a powerflow has been run) - to make sure a powergrid is in a consistent state, or to perform - some tests for example. - - In order to function properly, this method requires that :func:`Backend.shunt_info` and - :func:`Backend.sub_from_bus_id` are properly defined. Otherwise the results might be wrong, especially - for reactive values (q_subs and q_bus bellow) - - 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) - - """ - - 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) - 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) + @staticmethod + def _check_kirchhoff_lines(cls, info:KirchhoffInfo): 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): + if (info.topo_vect[cls.line_or_pos_topo_vect[i]] == -1 or + info.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 + loc_bus_or = info.topo_vect[cls.line_or_pos_topo_vect[i]] - 1 + loc_bus_ex = info.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] + info.p_subs[sub_or_id] += info.p_or[i] + info.p_subs[sub_ex_id] += info.p_ex[i] - q_subs[sub_or_id] += q_or[i] - q_subs[sub_ex_id] += q_ex[i] + info.q_subs[sub_or_id] += info.q_or[i] + info.q_subs[sub_ex_id] += info.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] + info.p_bus[sub_or_id, loc_bus_or] += info.p_or[i] + info.q_bus[sub_or_id, loc_bus_or] += info.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] + info.p_bus[ sub_ex_id, loc_bus_ex] += info.p_ex[i] + info.q_bus[sub_ex_id, loc_bus_ex] += info.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] + if (info.v_bus[sub_or_id, loc_bus_or,][0] == -1): + info.v_bus[sub_or_id, loc_bus_or,][0] = info.v_or[i] + if (info.v_bus[sub_ex_id, loc_bus_ex,][0] == -1): + info.v_bus[sub_ex_id, loc_bus_ex,][0] = info.v_ex[i] + if (info.v_bus[sub_or_id, loc_bus_or,][1]== -1): + info.v_bus[sub_or_id, loc_bus_or,][1] = info.v_or[i] + if (info.v_bus[sub_ex_id, loc_bus_ex,][1]== -1): + info.v_bus[sub_ex_id, loc_bus_ex,][1] = info.v_ex[i] # now compute the correct stuff - if v_or[i] > 0.0: + if info.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],) + info.v_bus[sub_or_id, loc_bus_or,][0] = min(info.v_bus[sub_or_id, loc_bus_or,][0],info.v_or[i],) + info.v_bus[sub_or_id, loc_bus_or,][1] = max(info.v_bus[sub_or_id, loc_bus_or,][1],info.v_or[i],) - if v_ex[i] > 0: + if info.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],) - + info.v_bus[sub_ex_id, loc_bus_ex,][0] = min(info.v_bus[sub_ex_id, loc_bus_ex,][0],info.v_ex[i],) + info.v_bus[sub_ex_id, loc_bus_ex,][1] = max(info.v_bus[sub_ex_id, loc_bus_ex,][1],info.v_ex[i],) + return info + + @staticmethod + def _check_kirchhoff_gens(cls, info:KirchhoffInfo): for i in range(cls.n_gen): gptv = cls.gen_pos_topo_vect[i] - if topo_vect[gptv] == -1: + if info.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] + info.p_subs[cls.gen_to_subid[i]] -= info.p_gen[i] + info.q_subs[cls.gen_to_subid[i]] -= info.q_gen[i] - loc_bus = topo_vect[gptv] - 1 + loc_bus = info.topo_vect[gptv] - 1 # for bus - p_bus[ + info.p_bus[ cls.gen_to_subid[i], loc_bus - ] -= p_gen[i] - q_bus[ + ] -= info.p_gen[i] + info.q_bus[ cls.gen_to_subid[i], loc_bus - ] -= q_gen[i] + ] -= info.q_gen[i] # compute max and min values - if v_gen[i]: + if info.v_gen[i]: # but only if gen is connected - v_bus[cls.gen_to_subid[i], loc_bus][ + info.v_bus[cls.gen_to_subid[i], loc_bus][ 0 ] = min( - v_bus[ + info.v_bus[ cls.gen_to_subid[i], loc_bus ][0], - v_gen[i], + info.v_gen[i], ) - v_bus[cls.gen_to_subid[i], loc_bus][ + info.v_bus[cls.gen_to_subid[i], loc_bus][ 1 ] = max( - v_bus[ + info.v_bus[ cls.gen_to_subid[i], loc_bus ][1], - v_gen[i], + info.v_gen[i], ) - + return info + + def _check_kirchhoff_loads(cls, info:KirchhoffInfo): for i in range(cls.n_load): gptv = cls.load_pos_topo_vect[i] - if topo_vect[gptv] == -1: + if info.topo_vect[gptv] == -1: # load is disconnected continue - loc_bus = topo_vect[gptv] - 1 + loc_bus = info.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] + info.p_subs[cls.load_to_subid[i]] += info.p_load[i] + info.q_subs[cls.load_to_subid[i]] += info.q_load[i] # for buses - p_bus[ + info.p_bus[ cls.load_to_subid[i], loc_bus - ] += p_load[i] - q_bus[ + ] += info.p_load[i] + info.q_bus[ cls.load_to_subid[i], loc_bus - ] += q_load[i] + ] += info.q_load[i] # compute max and min values - if v_load[i]: + if info.v_load[i]: # but only if load is connected - v_bus[cls.load_to_subid[i], loc_bus][ + info.v_bus[cls.load_to_subid[i], loc_bus][ 0 ] = min( - v_bus[ + info.v_bus[ cls.load_to_subid[i], loc_bus ][0], - v_load[i], + info.v_load[i], ) - v_bus[cls.load_to_subid[i], loc_bus][ + info.v_bus[cls.load_to_subid[i], loc_bus][ 1 ] = max( - v_bus[ + info.v_bus[ cls.load_to_subid[i], loc_bus ][1], - v_load[i], + info.v_load[i], ) - + return info + + def _check_kirchhoff_storage(cls, info:KirchhoffInfo): for i in range(cls.n_storage): gptv = cls.storage_pos_topo_vect[i] - if topo_vect[gptv] == -1: + if info.topo_vect[gptv] == -1: # storage is disconnected continue - loc_bus = topo_vect[gptv] - 1 + loc_bus = info.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[ + info.p_subs[cls.storage_to_subid[i]] += info.p_storage[i] + info.q_subs[cls.storage_to_subid[i]] += info.q_storage[i] + info.p_bus[ cls.storage_to_subid[i], loc_bus - ] += p_storage[i] - q_bus[ + ] += info.p_storage[i] + info.q_bus[ cls.storage_to_subid[i], loc_bus - ] += q_storage[i] + ] += info.q_storage[i] # compute max and min values - if v_storage[i] > 0: + if info.v_storage[i] > 0: # the storage unit is connected - v_bus[ + info.v_bus[ cls.storage_to_subid[i], loc_bus, ][0] = min( - v_bus[ + info.v_bus[ cls.storage_to_subid[i], loc_bus, ][0], - v_storage[i], + info.v_storage[i], ) - v_bus[ - self.storage_to_subid[i], + info.v_bus[ + cls.storage_to_subid[i], loc_bus, ][1] = max( - v_bus[ + info.v_bus[ cls.storage_to_subid[i], loc_bus, ][1], - v_storage[i], + info.v_storage[i], ) - + return info + + def _check_kirchhoff_shunt(cls, info:KirchhoffInfo): 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: + if info.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] + info.p_subs[cls.shunt_to_subid[i]] += info.p_s[i] + info.q_subs[cls.shunt_to_subid[i]] += info.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] + info.p_bus[cls.shunt_to_subid[i], info.bus_s[i] - 1] += info.p_s[i] + info.q_bus[cls.shunt_to_subid[i], info.bus_s[i] - 1] += info.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] + info.v_bus[cls.shunt_to_subid[i], info.bus_s[i] - 1][0] = min( + info.v_bus[cls.shunt_to_subid[i], info.bus_s[i] - 1][0], info.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] + info.v_bus[cls.shunt_to_subid[i], info.bus_s[i] - 1][1] = max( + info.v_bus[cls.shunt_to_subid[i], info.bus_s[i] - 1][1], info.v_s[i] ) else: warnings.warn( "Backend.check_kirchhoff Impossible to get shunt information. Reactive information might be " "incorrect." ) + return info + + def check_kirchhoff(self) -> Tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray, np.ndarray]: + """ + INTERNAL + + .. warning:: /!\\\\ Internal, do not use unless you know what you are doing /!\\\\ + + .. versionadded:: 1.11.0 + Fix the typo of the :attr:`Backend.check_kirchoff` function + + Check that the powergrid respects kirchhoff's law. + This function can be called at any moment (after a powerflow has been run) + to make sure a powergrid is in a consistent state, or to perform + some tests for example. + + In order to function properly, this method requires that :func:`Backend.shunt_info` and + :func:`Backend.sub_from_bus_id` are properly defined. Otherwise the results might be wrong, especially + for reactive values (q_subs and q_bus bellow) + + 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) + + """ + 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() + + if cls.n_storage > 0: + p_storage, q_storage, v_storage = self.storages_info() + else: + p_storage, q_storage, v_storage = (None, None, None) + + if cls.shunts_data_available: + p_s, q_s, v_s, bus_s = self.shunt_info() + else: + p_s, q_s, v_s, bus_s = (None, None, None, None) + + # 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() + info = KirchhoffInfo(p_or, q_or, v_or, + p_ex, q_ex, v_ex, + p_gen, q_gen, v_gen, + p_load, q_load, v_load, + p_subs, q_subs, + p_bus, q_bus, v_bus, + topo_vect, + p_storage, q_storage, v_storage, + p_s, q_s, v_s, bus_s) + + # 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) + info = Backend._check_kirchhoff_lines(info) + info = Backend._check_kirchhoff_gens(info) + info = Backend._check_kirchhoff_loads(info) + info = Backend._check_kirchhoff_storage(info) + if cls.shunts_data_available: + info = Backend._check_kirchhoff_shunt(info) 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] - return p_subs, q_subs, p_bus, q_bus, diff_v_bus + diff_v_bus[:, :] = info.v_bus[:, :, 1] - info.v_bus[:, :, 0] + return info.p_subs, info.q_subs, info.p_bus, info.q_bus, diff_v_bus def _fill_names_obj(self): """fill the name vectors (**eg** name_line) if not done already in the backend. From 42535de5bcd4cf1112cfabd2abd6874c22cd875e Mon Sep 17 00:00:00 2001 From: Xavier Weiss Date: Mon, 2 Dec 2024 16:27:25 +0100 Subject: [PATCH 35/39] Debug: Fix missing param in kirchhoff static methods Signed-off-by: Xavier Weiss --- grid2op/Backend/backend.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/grid2op/Backend/backend.py b/grid2op/Backend/backend.py index ed019e64..ba8eb402 100644 --- a/grid2op/Backend/backend.py +++ b/grid2op/Backend/backend.py @@ -1567,10 +1567,10 @@ def check_kirchhoff(self) -> Tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarra # 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) - info = Backend._check_kirchhoff_lines(info) - info = Backend._check_kirchhoff_gens(info) - info = Backend._check_kirchhoff_loads(info) - info = Backend._check_kirchhoff_storage(info) + info = Backend._check_kirchhoff_lines(cls, info) + info = Backend._check_kirchhoff_gens(cls, info) + info = Backend._check_kirchhoff_loads(cls, info) + info = Backend._check_kirchhoff_storage(cls, info) if cls.shunts_data_available: info = Backend._check_kirchhoff_shunt(info) diff_v_bus = np.zeros((cls.n_sub, cls.n_busbar_per_sub), dtype=dt_float) From 9a6b696a8b1e7996d41c2cf2a2dc36e9592694c9 Mon Sep 17 00:00:00 2001 From: Xavier Weiss Date: Mon, 2 Dec 2024 16:49:13 +0100 Subject: [PATCH 36/39] Refact: Emulate obs._check_kirchhoff in Backend._check_kirchhoff Signed-off-by: Xavier Weiss --- grid2op/Backend/backend.py | 338 ++++++++++--------------------------- 1 file changed, 89 insertions(+), 249 deletions(-) diff --git a/grid2op/Backend/backend.py b/grid2op/Backend/backend.py index ba8eb402..48fccb93 100644 --- a/grid2op/Backend/backend.py +++ b/grid2op/Backend/backend.py @@ -15,7 +15,6 @@ from abc import ABC, abstractmethod import numpy as np import pandas as pd -from dataclasses import dataclass from typing import Tuple, Optional, Any, Dict, Union try: @@ -39,37 +38,7 @@ from grid2op.Space import GridObjects, DEFAULT_N_BUSBAR_PER_SUB, DEFAULT_ALLOW_DETACHMENT -@dataclass -class KirchhoffInfo: - p_or:np.ndarray - q_or:np.ndarray - v_or:np.ndarray - p_ex:np.ndarray - q_ex:np.ndarray - v_ex:np.ndarray - p_gen:np.ndarray - q_gen:np.ndarray - v_gen:np.ndarray - p_load:np.ndarray - q_load:np.ndarray - v_load:np.ndarray - p_subs:np.ndarray - q_subs:np.ndarray - p_bus:np.ndarray - q_bus:np.ndarray - v_bus:np.ndarray - topo_vect:np.ndarray - p_storage:Optional[np.ndarray] = None - q_storage:Optional[np.ndarray] = None - v_storage:Optional[np.ndarray] = None - p_s:Optional[np.ndarray] = None - q_s:Optional[np.ndarray] = None - v_s:Optional[np.ndarray] = None - bus_s:Optional[np.ndarray] = None - # TODO method to get V and theta at each bus, could be in the same shape as check_kirchoff - - class Backend(GridObjects, ABC): """ INTERNAL @@ -1284,210 +1253,53 @@ 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() - @staticmethod - def _check_kirchhoff_lines(cls, info:KirchhoffInfo): - 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 (info.topo_vect[cls.line_or_pos_topo_vect[i]] == -1 or - info.topo_vect[cls.line_ex_pos_topo_vect[i]] == -1): - # line is disconnected + def _aux_check_kirchhoff(self, + n_elt: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, + # whether the object is load convention (True) or gen convention (False) + load_conv:np.ndarray=True): + for i in range(n_elt): + psubid = el_to_subid[i] + if el_bus[i] == -1: + # el is disconnected continue - loc_bus_or = info.topo_vect[cls.line_or_pos_topo_vect[i]] - 1 - loc_bus_ex = info.topo_vect[cls.line_ex_pos_topo_vect[i]] - 1 # for substations - info.p_subs[sub_or_id] += info.p_or[i] - info.p_subs[sub_ex_id] += info.p_ex[i] - - info.q_subs[sub_or_id] += info.q_or[i] - info.q_subs[sub_ex_id] += info.q_ex[i] - - # for bus - info.p_bus[sub_or_id, loc_bus_or] += info.p_or[i] - info.q_bus[sub_or_id, loc_bus_or] += info.q_or[i] - - info.p_bus[ sub_ex_id, loc_bus_ex] += info.p_ex[i] - info.q_bus[sub_ex_id, loc_bus_ex] += info.q_ex[i] - - # fill the min / max voltage per bus (initialization) - if (info.v_bus[sub_or_id, loc_bus_or,][0] == -1): - info.v_bus[sub_or_id, loc_bus_or,][0] = info.v_or[i] - if (info.v_bus[sub_ex_id, loc_bus_ex,][0] == -1): - info.v_bus[sub_ex_id, loc_bus_ex,][0] = info.v_ex[i] - if (info.v_bus[sub_or_id, loc_bus_or,][1]== -1): - info.v_bus[sub_or_id, loc_bus_or,][1] = info.v_or[i] - if (info.v_bus[sub_ex_id, loc_bus_ex,][1]== -1): - info.v_bus[sub_ex_id, loc_bus_ex,][1] = info.v_ex[i] - - # now compute the correct stuff - if info.v_or[i] > 0.0: - # line is connected - info.v_bus[sub_or_id, loc_bus_or,][0] = min(info.v_bus[sub_or_id, loc_bus_or,][0],info.v_or[i],) - info.v_bus[sub_or_id, loc_bus_or,][1] = max(info.v_bus[sub_or_id, loc_bus_or,][1],info.v_or[i],) - - if info.v_ex[i] > 0: - # line is connected - info.v_bus[sub_ex_id, loc_bus_ex,][0] = min(info.v_bus[sub_ex_id, loc_bus_ex,][0],info.v_ex[i],) - info.v_bus[sub_ex_id, loc_bus_ex,][1] = max(info.v_bus[sub_ex_id, loc_bus_ex,][1],info.v_ex[i],) - return info - - @staticmethod - def _check_kirchhoff_gens(cls, info:KirchhoffInfo): - for i in range(cls.n_gen): - gptv = cls.gen_pos_topo_vect[i] - - if info.topo_vect[gptv] == -1: - # gen is disconnected - continue - - # for substations - info.p_subs[cls.gen_to_subid[i]] -= info.p_gen[i] - info.q_subs[cls.gen_to_subid[i]] -= info.q_gen[i] + 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] - loc_bus = info.topo_vect[gptv] - 1 # for bus - info.p_bus[ - cls.gen_to_subid[i], loc_bus - ] -= info.p_gen[i] - info.q_bus[ - cls.gen_to_subid[i], loc_bus - ] -= info.q_gen[i] + 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 info.v_gen[i]: + if el_v is not None and el_v[i]: # but only if gen is connected - info.v_bus[cls.gen_to_subid[i], loc_bus][ - 0 - ] = min( - info.v_bus[ - cls.gen_to_subid[i], loc_bus - ][0], - info.v_gen[i], - ) - info.v_bus[cls.gen_to_subid[i], loc_bus][ - 1 - ] = max( - info.v_bus[ - cls.gen_to_subid[i], loc_bus - ][1], - info.v_gen[i], - ) - return info - - def _check_kirchhoff_loads(cls, info:KirchhoffInfo): - for i in range(cls.n_load): - gptv = cls.load_pos_topo_vect[i] - - if info.topo_vect[gptv] == -1: - # load is disconnected - continue - loc_bus = info.topo_vect[gptv] - 1 - - # for substations - info.p_subs[cls.load_to_subid[i]] += info.p_load[i] - info.q_subs[cls.load_to_subid[i]] += info.q_load[i] - - # for buses - info.p_bus[ - cls.load_to_subid[i], loc_bus - ] += info.p_load[i] - info.q_bus[ - cls.load_to_subid[i], loc_bus - ] += info.q_load[i] - - # compute max and min values - if info.v_load[i]: - # but only if load is connected - info.v_bus[cls.load_to_subid[i], loc_bus][ - 0 - ] = min( - info.v_bus[ - cls.load_to_subid[i], loc_bus - ][0], - info.v_load[i], - ) - info.v_bus[cls.load_to_subid[i], loc_bus][ - 1 - ] = max( - info.v_bus[ - cls.load_to_subid[i], loc_bus - ][1], - info.v_load[i], + v_bus[psubid, loc_bus][0] = min( + v_bus[psubid, loc_bus][0], + el_v[i], ) - return info - - def _check_kirchhoff_storage(cls, info:KirchhoffInfo): - for i in range(cls.n_storage): - gptv = cls.storage_pos_topo_vect[i] - if info.topo_vect[gptv] == -1: - # storage is disconnected - continue - loc_bus = info.topo_vect[gptv] - 1 - - info.p_subs[cls.storage_to_subid[i]] += info.p_storage[i] - info.q_subs[cls.storage_to_subid[i]] += info.q_storage[i] - info.p_bus[ - cls.storage_to_subid[i], loc_bus - ] += info.p_storage[i] - info.q_bus[ - cls.storage_to_subid[i], loc_bus - ] += info.q_storage[i] - - # compute max and min values - if info.v_storage[i] > 0: - # the storage unit is connected - info.v_bus[ - cls.storage_to_subid[i], - loc_bus, - ][0] = min( - info.v_bus[ - cls.storage_to_subid[i], - loc_bus, - ][0], - info.v_storage[i], - ) - info.v_bus[ - cls.storage_to_subid[i], - loc_bus, - ][1] = max( - info.v_bus[ - cls.storage_to_subid[i], - loc_bus, - ][1], - info.v_storage[i], + v_bus[psubid, loc_bus][1] = max( + v_bus[psubid, loc_bus][1], + el_v[i], ) - return info - - def _check_kirchhoff_shunt(cls, info:KirchhoffInfo): - if cls.shunts_data_available: - for i in range(cls.n_shunt): - if info.bus_s[i] == -1: - # shunt is disconnected - continue - - # for substations - info.p_subs[cls.shunt_to_subid[i]] += info.p_s[i] - info.q_subs[cls.shunt_to_subid[i]] += info.q_s[i] - - # for buses - info.p_bus[cls.shunt_to_subid[i], info.bus_s[i] - 1] += info.p_s[i] - info.q_bus[cls.shunt_to_subid[i], info.bus_s[i] - 1] += info.q_s[i] - # compute max and min values - info.v_bus[cls.shunt_to_subid[i], info.bus_s[i] - 1][0] = min( - info.v_bus[cls.shunt_to_subid[i], info.bus_s[i] - 1][0], info.v_s[i] - ) - info.v_bus[cls.shunt_to_subid[i], info.bus_s[i] - 1][1] = max( - info.v_bus[cls.shunt_to_subid[i], info.bus_s[i] - 1][1], info.v_s[i] - ) - else: - warnings.warn( - "Backend.check_kirchhoff Impossible to get shunt information. Reactive information might be " - "incorrect." - ) - return info - def check_kirchhoff(self) -> Tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray, np.ndarray]: """ INTERNAL @@ -1534,13 +1346,9 @@ def check_kirchhoff(self) -> Tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarra if cls.n_storage > 0: p_storage, q_storage, v_storage = self.storages_info() - else: - p_storage, q_storage, v_storage = (None, None, None) if cls.shunts_data_available: p_s, q_s, v_s, bus_s = self.shunt_info() - else: - p_s, q_s, v_s, bus_s = (None, None, None, None) # fist check the "substation law" : nothing is created at any substation p_subs = np.zeros(cls.n_sub, dtype=dt_float) @@ -1553,29 +1361,61 @@ def check_kirchhoff(self) -> Tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarra 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() - info = KirchhoffInfo(p_or, q_or, v_or, - p_ex, q_ex, v_ex, - p_gen, q_gen, v_gen, - p_load, q_load, v_load, - p_subs, q_subs, - p_bus, q_bus, v_bus, - topo_vect, - p_storage, q_storage, v_storage, - p_s, q_s, v_s, bus_s) - - # 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) - info = Backend._check_kirchhoff_lines(cls, info) - info = Backend._check_kirchhoff_gens(cls, info) - info = Backend._check_kirchhoff_loads(cls, info) - info = Backend._check_kirchhoff_storage(cls, info) + + self._aux_check_kirchhoff( + cls.n_line, + cls.line_or_to_subid, + topo_vect[cls.line_or_pos_topo_vect], + p_or, q_or, v_or, + p_subs, q_subs, + p_bus, q_bus, v_bus) + self._aux_check_kirchhoff( + cls.n_line, + cls.line_ex_to_subid, + topo_vect[cls.line_ex_pos_topo_vect], + p_ex, q_ex, v_ex, + p_subs, q_subs, + p_bus, q_bus, v_bus) + self._aux_check_kirchhoff( + cls.n_load, + cls.load_to_subid, + topo_vect[cls.load_pos_topo_vect], + p_load, q_load, v_load, + p_subs, q_subs, + p_bus, q_bus, v_bus) + self._aux_check_kirchhoff( + cls.n_gen, + cls.gen_to_subid, + topo_vect[cls.gen_pos_topo_vect], + p_gen, q_gen, v_gen, + p_subs, q_subs, + p_bus, q_bus, v_bus, + load_conv=False) + if cls.n_storage: + self._aux_check_kirchhoff( + cls.n_storage, + cls.storage_to_subid, + topo_vect[cls.storage_pos_topo_vect], + p_storage, q_storage, v_storage, + p_subs, q_subs, + p_bus, q_bus, v_bus) + if cls.shunts_data_available: - info = Backend._check_kirchhoff_shunt(info) + self._aux_check_kirchhoff( + cls.n_shunt, + cls.shunt_to_subid, + bus_s, + p_s, q_s, v_s, + 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[:, :] = info.v_bus[:, :, 1] - info.v_bus[:, :, 0] - return info.p_subs, info.q_subs, info.p_bus, info.q_bus, diff_v_bus + diff_v_bus[:, :] = v_bus[:, :, 1] - v_bus[:, :, 0] + return p_subs, q_subs, p_bus, q_bus, diff_v_bus def _fill_names_obj(self): """fill the name vectors (**eg** name_line) if not done already in the backend. From 5851026ce1ab188fb9a71c0beb149d74bb8170a8 Mon Sep 17 00:00:00 2001 From: DONNOT Benjamin Date: Wed, 4 Dec 2024 09:28:12 +0100 Subject: [PATCH 37/39] refactoring check_kirchhoff methods of Observation and Backend, should fix some issues Signed-off-by: DONNOT Benjamin --- grid2op/Backend/backend.py | 142 ++++------------- grid2op/Observation/baseObservation.py | 145 +++-------------- grid2op/Space/GridObjects.py | 213 ++++++++++++++++++++++++- grid2op/Space/__init__.py | 2 + grid2op/Space/space_utils.py | 35 ++++ 5 files changed, 309 insertions(+), 228 deletions(-) diff --git a/grid2op/Backend/backend.py b/grid2op/Backend/backend.py index 48fccb93..2dc61d7b 100644 --- a/grid2op/Backend/backend.py +++ b/grid2op/Backend/backend.py @@ -35,7 +35,7 @@ DivergingPowerflow, Grid2OpException, ) -from grid2op.Space import GridObjects, DEFAULT_N_BUSBAR_PER_SUB, DEFAULT_ALLOW_DETACHMENT +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 @@ -1252,53 +1252,6 @@ 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 _aux_check_kirchhoff(self, - n_elt: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, - # whether the object is load convention (True) or gen convention (False) - load_conv:np.ndarray=True): - for i in range(n_elt): - 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]: """ @@ -1343,78 +1296,53 @@ def check_kirchhoff(self) -> Tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarra 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() - - if cls.n_storage > 0: - p_storage, q_storage, v_storage = self.storages_info() - - if cls.shunts_data_available: - p_s, q_s, v_s, bus_s = self.shunt_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() - - self._aux_check_kirchhoff( - cls.n_line, - cls.line_or_to_subid, + lineor_info = ElTypeInfo( topo_vect[cls.line_or_pos_topo_vect], - p_or, q_or, v_or, - p_subs, q_subs, - p_bus, q_bus, v_bus) - self._aux_check_kirchhoff( - cls.n_line, - cls.line_ex_to_subid, + p_or, + q_or, + v_or, + ) + lineex_info = ElTypeInfo( topo_vect[cls.line_ex_pos_topo_vect], - p_ex, q_ex, v_ex, - p_subs, q_subs, - p_bus, q_bus, v_bus) - self._aux_check_kirchhoff( - cls.n_load, - cls.load_to_subid, + p_ex, + q_ex, + v_ex, + ) + load_info = ElTypeInfo( topo_vect[cls.load_pos_topo_vect], - p_load, q_load, v_load, - p_subs, q_subs, - p_bus, q_bus, v_bus) - self._aux_check_kirchhoff( - cls.n_gen, - cls.gen_to_subid, + p_load, + q_load, + v_load, + ) + gen_info = ElTypeInfo( topo_vect[cls.gen_pos_topo_vect], p_gen, q_gen, v_gen, - p_subs, q_subs, - p_bus, q_bus, v_bus, - load_conv=False) - if cls.n_storage: - self._aux_check_kirchhoff( - cls.n_storage, - cls.storage_to_subid, + ) + if cls.n_storage > 0: + p_storage, q_storage, v_storage = self.storages_info() + storage_info = ElTypeInfo( topo_vect[cls.storage_pos_topo_vect], p_storage, q_storage, v_storage, - p_subs, q_subs, - p_bus, q_bus, v_bus) + ) + else: + storage_info = None if cls.shunts_data_available: - self._aux_check_kirchhoff( - cls.n_shunt, - cls.shunt_to_subid, + p_s, q_s, v_s, bus_s = self.shunt_info() + shunt_info = ElTypeInfo( bus_s, p_s, q_s, v_s, - 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] + 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): diff --git a/grid2op/Observation/baseObservation.py b/grid2op/Observation/baseObservation.py index 72370ec5..28b1f257 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 @@ -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/Space/GridObjects.py b/grid2op/Space/GridObjects.py index 8739e547..e29048c6 100644 --- a/grid2op/Space/GridObjects.py +++ b/grid2op/Space/GridObjects.py @@ -29,7 +29,7 @@ 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 @@ -5261,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 14b2c8b3..7b3683b9 100644 --- a/grid2op/Space/__init__.py +++ b/grid2op/Space/__init__.py @@ -1,6 +1,7 @@ __all__ = ["RandomObject", "SerializableSpace", "GridObjects", + "ElTypeInfo", "DEFAULT_N_BUSBAR_PER_SUB", "GRID2OP_CLASSES_ENV_FOLDER", "DEFAULT_ALLOW_DETACHMENT"] @@ -8,6 +9,7 @@ from grid2op.Space.RandomObject import RandomObject from grid2op.Space.SerializableSpace import SerializableSpace from grid2op.Space.GridObjects import (GridObjects, + ElTypeInfo, DEFAULT_N_BUSBAR_PER_SUB, GRID2OP_CLASSES_ENV_FOLDER, DEFAULT_ALLOW_DETACHMENT) diff --git a/grid2op/Space/space_utils.py b/grid2op/Space/space_utils.py index 0ab3dc95..285bc955 100644 --- a/grid2op/Space/space_utils.py +++ b/grid2op/Space/space_utils.py @@ -7,6 +7,9 @@ # This file is part of Grid2Op, Grid2Op a testbed platform to model sequential decision making in power systems. import copy +from typing import Optional +import numpy as np + from grid2op.Exceptions import Grid2OpException # i already issued the warning for the "some substations have no controllable elements" @@ -74,3 +77,35 @@ def save_to_dict(res_dict, me, key, converter, copy_=True): ) raise Grid2OpException(msg_err_.format(key)) res_dict[key] = res + + +class ElTypeInfo: + """ + For each element type (*eg* generator, or load) this is a container for basic element + information, it is used in the `check_kirchhoff` functions of observation :func:`grid2op.Observation.BaseObservation.check_kirchhoff` + and backend :func:`grid2op.Backend.Backend.check_kirchhoff` + + The element it contains are: + + - el_bus : np.ndarray + for each element of this element type, on which busbar this element is connected (*eg* obs.gen_bus) + - el_p : np.ndarray + for each element of this element type, it gives the active power value consumed / produced by this element (*eg* obs.gen_p) + - el_q : np.ndarray + for each element of this element type, it gives the reactive power value consumed / produced by this element (*eg* obs.gen_q) + - el_v : np.ndarray + for each element of this element type, it gives the voltage magnitude of the bus to which this element (*eg* obs.gen_v) + is connected + + """ + def __init__(self, + el_bus : np.ndarray, + el_p : np.ndarray, + el_q : np.ndarray, + el_v : Optional[np.ndarray] = None, + # load_conv: bool = True + ): + self._bus = el_bus + self._p = el_p + self._q = el_q + self._v = el_v From 1bdce5df9a0ccdcb283bfb61481d913a1391cf65 Mon Sep 17 00:00:00 2001 From: DONNOT Benjamin Date: Thu, 12 Dec 2024 13:45:30 +0100 Subject: [PATCH 38/39] adding a kwargs to set the initial time stamp of the observation Signed-off-by: DONNOT Benjamin --- CHANGELOG.rst | 5 +- grid2op/Chronics/gridStateFromFile.py | 12 ++-- grid2op/Chronics/gridValue.py | 76 +++++++++++++++++++++++++- grid2op/Chronics/multiFolder.py | 7 +++ grid2op/Environment/baseEnv.py | 6 +- grid2op/Environment/environment.py | 56 ++++++++++++++++--- grid2op/tests/test_new_reset.py | 49 +++++++++++++++++ grid2op/typing_variables.py | 3 + logo/final_logo.png | Bin 153339 -> 152937 bytes logo/final_logo_blue.png | Bin 201502 -> 201189 bytes logo/final_logo_green.png | Bin 195811 -> 195300 bytes 11 files changed, 194 insertions(+), 20 deletions(-) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 5a773177..ecb784bc 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -123,6 +123,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 +132,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 @@ -154,7 +157,7 @@ Native multi agents support: 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/grid2op/Chronics/gridStateFromFile.py b/grid2op/Chronics/gridStateFromFile.py index 4874a51a..009c9d5c 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 ec47b450..bb36f605 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 9e71b8da..a8dc3f6a 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/Environment/baseEnv.py b/grid2op/Environment/baseEnv.py index 042e7352..2bf1f283 100644 --- a/grid2op/Environment/baseEnv.py +++ b/grid2op/Environment/baseEnv.py @@ -306,6 +306,7 @@ def foo(manager): "init ts", "max step", "thermal limit", + "init datetime", } def __init__( @@ -3848,7 +3849,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 +3941,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 ce4ccd90..90a8b704 100644 --- a/grid2op/Environment/environment.py +++ b/grid2op/Environment/environment.py @@ -923,7 +923,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 +1043,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 +1276,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 +1341,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 +1359,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 +1368,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 diff --git a/grid2op/tests/test_new_reset.py b/grid2op/tests/test_new_reset.py index a96eac4f..7baf1920 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/typing_variables.py b/grid2op/typing_variables.py index ea19ec21..9ac1ef7c 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 644966a1c49e1f6d67b9a8920c23a6a428a756c4..8426ccf58cd93b73a94874fe992e2295d5ea2fb5 100644 GIT binary patch delta 21544 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 delta 32393 zcmZU*Ra9N!vaL&yAPMg7?(P;O5InfMyE`MnU4vV2_uv-XA-F?uclSH8?%wCD*3QFx zgV}~u{ne}2S92%tV^-nEZ-MWAL1rfcEYhW=QE#O!rF;WJ?EHrAiv$ZWZbPCJ0VFc9 z3}m6?)=e^XmS^YpEHvi}IjnqqEzU|kJ;+MU zB5ktD-di;HvR{2#Q=V-+^F3N|o>=@mG>c;tZ)#Em)pts;Wc5ok&c;o43NFwSpvdLa zs+D~A%Y#g;g$aVCXu zX$>hH`U5M`BR}a3{97 zWFEwq3&l%sKLxC%&-z`9Yb+*lfIb~O1qo3Un(Ri1gs(m<3XGXlbE^`+$^<7qT|36o zL-ZYVb9?)3P^C%Ud7sI89DT0UQqmzZNye`miAup9b@d40v*P&?WSuF1f1Oj)Fno2al8GHP~U67M1)3h)`mR=EWG zScdVGSY)V>$k1TTUZ$UBCCZILrb^aJsy};S(f1DsuU&K+XLe`d0 z?Z@@Krr8Uhj`IFW?k`O;vm`ruMqK|YMaECibch#c%Z1hju`!pV6gr{t)x$=GiHC-{ zPi~^$H6P*j>LLnJT@1aU6i71?b*4bwC!drHBaC9eJG3qmEjCt<3XvPf;m-B8nyUOxRB_ZH zE3sNlrDL}M7fstiH~iYMXz3(jlu5b;x`-~z1C9faNEHE5J|cFqRe(AnKS7#9m9m0r z5>1*M&yOuENj6$^?!(Y$nM#9i28sV}J5P-{uFN0D7$XYbctzvQbK6Qp){H%?pO)g2 zC>fz`p5L86&1P|BpPrHg83x5dZHS8#!`@9dg60Ul!<_7=A10fxM4xOcwtDeAZ7?V_ z@aHfb7T!Dtg4;{60CO?JTF9T)2cvVlgX0?|8j%|Aur+I0hV}=(k{B({!N8n_rWK&%~N5Xr-I60zItvr`n+}6xIawk#OYTIh6d0F zC_8bI_>&k^RrVdbn9CzYqrlR$bD5M8svDEXFMi3di;~)-kv}J=&Bu@abDPFzlZtLN zc`Dycc#;P^5k|Q%Yf1#csog!^NmHW*;bXiL-U!!JwNagI!lS}2pXs!vzwiU7xPKzn zO$Tf`k@IK31jHYea^i*XqLq?Ld z3oM#!rl}dxi@i@hBwFO!gOb08ODuf;Ok0vCNb(6b1qq?Z5ktsvv&q1Hlb1&tIwrv* z{ue8=fvgBzjB#bT_{Vt(+nruEgGjKa(sI3Ctzj2Zij0)J<~sFJ zKB35^tP(L05r@x5SPVCAS`1OttTAo23mWrW#^h^nc(Z!9d6;)o-@mjZ$VigW(33_Jjxo?F6kI+)OS?<9Xv$YOn< zk`PtFpjf=O#=AG?dJV7TJ*umDVTk-C$BQ;JZ2v>se*3o(j2q8q{&-6ah1mb+<~!K9 z#1r7nb)l#vNdL5rkWl7DA|~YiEwyJe(!04uv9iV8Xu4T226~Qf|CU>e)N1^hq1cG# zG7?`SeZ85=2G_;*U=y4)!7UkGT8aba`NaB5*XayWSg(`e0fEnPx<(x(x|J)0Ms z<(cBe?TJmD?xT-t_PFDZ@Bff4EXl5%O&oUKgEa(zY#6pWo~;k1LND>iaClmig%~%4 zApaX>(}mYJaAb^wzjna>;gKEY?wDd}mUaBp#Uvf7Za!jBJ=msxZ&oo66|G&c_vu9p z>9vpPr@zRKzeMPljRfD!vZTDSl1bL^_!x^d5s?~X8(O@4YuWBZO!xOPJ2iJKw?WLnz}&MTRd^B#t8uPN~5`7=%ok^ey<5S1`Fh4(#4(D+d@)r383NICArZN?$R z;3zAHvCMHTo@Ru{n%a<&9nz(=h9>T|z4z$}z_p&L&~Y$WQ(G#AJ~GC`H+9}6U%m{B z1x6&}yI!B)Z)u3gVSP}uZx(Ghj&3_T5ce0?XTEq9nlbn(GTx(8<1unBo2`1DhKD%a zdiyeo!SGe6-&2gm2i7zhf6=segDv$lTq^0e*sW)q{1Byp@1mQg8nAWzba7=XyKlPN zfYqmyh+?C7Gh)}PKz`b|vJ06oov84Q+SHOu9#rVivaKk{_OpaCghr_3F1&e1XD)MM(vOrE)m2vMG-^# z1?ODk$WiyHQTN3f7Gh7_7}nTEiI`hTf%xzko2Xj#`5j)f&#n9+x&C`DD!(N2^f<%F zuF|YRdlM*>gDMCsr4eBW`+IE1_F;^rF{?tdzQlcm4RdwhoJ8{&qw;s;L=V#z`9Wb4 zf^O|e*>Je@v5Tbv$CZ=Ys*Pr(sYreT9$M?H#*kYk5S=#O*O%h4zriY+>ETK;5t!&Z z2su;GZBEa6Wr24sH@@0R=jKqAKn#((zD2RMr(Bvf?4A0miUKWL8-JvqK&l0v^ItJc ziay9_!rZntUy%Txs{hy1+KUHt zEXTFDC*=cNFYoo)gFR+@`@cZrM5h>ynh8@IvdT^#xw>-cNYl|UJCDN>1w=!GoAb%b zM6K2GnxWjlo2f+!M$NJ9?~Ixp^pgWJRD{2FDUpjiq%%tbMh;(f=i!gng0kcd>8rZc z^ulJh$Y(l|E!^jMIH#5JP`w)MdN=8NRJCJl>NisP2rhb_V6C4el3H1%)d0ouEHZKSb8c&WlX%NA)f) zVD1iaj@{>pdFeh4@wxyZTPrOtgeAS=TS;Bg5qsyq-I)q6R*s|!g8O58tac0dJA)}w z6=yl-*Ip=?%U5_1reL$Ve~D*!kjb|-;zM+qd~$07PeXG3>}gYIVM4RXPq0cq6j$_q zlr!qf$;BbSMu`t@pjgpZw8j1yoW1}4WgCGe{QsWTK$#7%ksG-?a3E_k)VpP0xp5-r zy^kbWeA9Bj{rcoMYZ>CWru1~x+)8Brk~NO1cx&rqiY7tSb}Jy8J2>0<@ZsQBe_|2Q z@YcM3_hO^a;NeNd*1VzkDuQvomXbRH1Oy3$w794WI{fo8mvL2bq0OU>zxQ42#~2qY zMe>DO0CqCp;KB=)0$$x0eeM#$e{4)5gDY^wdot|FTk87G5Y!ZUno&PBJR8YPG$?aX zD|t>K*4C;c>RXp;R8{L50UUT7`D!7vvO*ecc|RM12_`evlm++nXzDPRU#JHbVn0lK z5ccWC9Gfl9muiO4s_cj_-%4FHc<#OpF=+k)26tkZMu2FDyfda~(xAPll6uU09XF(x zDFuszLou!$?Z-=&ej2(jF~{*PX(m5oOn`NR)FB&D$L82fCXguc851=(dx6en=4X`NQZp_{Wln8>(sBh{k)CBz03IS zP}MH8Sf>w%iz%?izkNKmzo=l?m|}u*qwW(v4%)#f3F_g%Dod)0fMl`j84LZ?p9MIs zGx3HAd|4cN?UNs4Dbhe&W3qwv2+aWlLJXo{Ce_N)r}Cl?d-;-n508HumiA(1J)L8+ zLz2(BH;3-bhieRcM-wO%&wGhuF+F`a%8||A&CXCvR=VZto`5CnaL9QPE$Sj7d48`K z?Q#+0^?s4O`EU?d=kEAWKtslpcQSeoew31Yp4k~cX%9ScT7b%!rm;t*xUx8=0(3{C zRYxP-qcW24L9SNOCLKGI5U3%eiHs6r$Vt|6z1(txizLH~L_{Mi+C&weNIaxG$)5af z(itg_nIEd)NrMy#NlYE7Bz_S{hsA3j`RTu(w-nX)b)EXaiV8s;pFLxWnR0mE57z2M zK=#D*eGJ3PhUv;pkb+}BGBH8hoXQSq;@rYA`foBBg4Q>`8TBUkf%vw1OYT zQ({@$RX)gp=cz1gx-N>@S$B}M8d)I#go?txGzLU>pxE0jNSNz)>{z&oVG{b1K5K5P z$WXyIHg4XulvL3hznmg*=oaKh`iZgNRok{^w^ml}Lj!htg3g7e2G*yg-w87u@f##V4k&EWl@bZ? zL#G@KnC#jS@w359I49770D*Nf%@7R{^Ore|*~!UV*7&$TBpYAJ4avJ4L9-!7>F-t` zYdB4oVxMDMb&SaWjp*6)_XEr+^2!Ct({>LppYN=aSpNCKh1B~kYxxL<_>dRhy>*m)kVDCywxnQ7cRbMDU%$##-5j&R~ z!}Ng* zQDbJyH)%b7M%@<*W!l2Iq`JEH+T>r|mJ|2CrG0VUlAv}xBA>YWEu7>ySWe}4 z+efJ}#zv*IJ&7;;*;+xE+UIFL-~>~1vta@VkBHIl-QAV%u7RFDqGwH&eLk)q6KM1U z>?@u8#}7irw{Gd>2Mk&3%JQ%y7$y~Wwxa5n9}cUsep=(Os!^Z|w;e0%IINICZs1f1 zzB?RV1=G^~)YofaHRfe&$~Du3#bBG)1Qzss9zusw#mIsg&6)-(PgfH_e|ZT#J?{IbT@9$WqGS!wRf3V-Hk zKwsyRwY#vX9qfmqS@h6ekJ}2)4A<*-Iv(9!K6~5>j%STbREcwN1DmoX6@P&OckYYk zWndy#Q*PxhQ%0(Qlk5Uo*^1u7aYvHr?IJ*vnLB96ifuvI1x;llmMH{%@qnWNi%{Mq z9r4;<`W6L+Wzk0C#4WH~!!p>oqozJ3TtboD@N1lu{PvlnG?+pm|EUMj&fNJBU7+fR zH^#8hB2y2-%c!-W($)f6HQVcK?+g5EuiGoGgN%Kg-d;31JO>{E z>;kSm=FOKtcK#|cgGY~W>!ypmpa<8w-(TJ=A7eyIIk6FwC67o)*L%HN4H(OZuS#QhUl+T-mC?Vrf%UiN;aHScySHyv!hyt_YI zTD`W>n}{rweg+=t2kI)@vzY~J{8zn~;Ob9urtEf;WaM{i@R@cZ&(;ST=1wOur(AP& zB}<8Vs2P$`RCpLxu2RJ;4k8?m5%20uTQ63hGPvxT&Y_8#PtnF7y3Rc8wLd^4XFJif z^1oyN%fyPxmhyT6_{pn0<8RNyasr)~4W!g}_@|~#D}dLcrOQna&!e?_O<4~Qk3kY; zhu;vc&s%R$3iNmLx%!WD!ggJLE1tKc&l?Xr&lASXsN)3vDg4c8{2fh!)OIZy%2lo2 z)jAl^rw{!w1OjDYWxxe>mTrTe!)TVVAyt#f+6azXKk!U}r|< zotnYzJ@9AdWwM@BAf3$d3?{@dPFCk)pDn3kS-!joU65eAHUViP=SWzFCC$J(V&iUM z5#MB-lG}Nz%h}^Ee{%KPo*#>pR)_OMwq}bF6!N+U?g!>4!|~g{k->CX_g#tQYjdL# zjY1;M!3LbFi5Jsl;~PWTor9L^ub9i)AxMfDj=(mgo=$UI08=O99!2iF<=&~i-eA<7 z;F$h8Te;R}=9KXOZk_(*34!2=Oedc8hcV@6;|DJYq;&4Yx7GuMaX~j!z~>@!Gt0a% zJV`#`+X$EcA6bfZ9pIxL^toe5;@&avnCH zRdEerW=l`9`%6V9T8tG!-oxz z_-ug-Iy249&&gTcGG&bb?gt4;W-yDc+y&)1STW?D`)0L0A6{%TT0G{8p+Eec@3a59*#z9d+&%JKhGuQ%Il`4pJh4Ha0@*{0`^Fo3--W!FB$eBhge01E<%O2 z?goQ!S6kmuD)>day~3;LoaX5Xb9(@#xg5<}6UHni-N7AO|8+Lo^Ep_iX{V0bx$6WO zvqHPADtRsH=v+2a2xn_QEzcZHecI=yBvE{2cEMUDmyqyYOsT_jV!G*A&CjRr0GIxU ze9&@NW%OECMGpXceADaqFtMT=3u=5J9XL!?R9<q%G zU@u3#`I`>90F#Hm`{nDi;}62mgjFxkM?MdcdH2}^deDC(d-j}HzO(Z@?LKMNSl$^B zr&V6fH_lD15^sj3Gn;!RsoHVy+MHq#J#`q$)zYA;3IW)Ni9)Zn<3hm*t*KYUJ_+NBADyXM+A1vt8k(_Rh|ezDonx2|Q=_QfuKFl# ztPi&1m;Dsj=#Pg_qxG1cyIurd^2+Wnu#+8b?@n=T$RXxepOP7zOlkhQF5R!aOq`FV zi&=*0=>zSF$?CS7EfESE%kILwQk4*EdW``EXXSeduw&aX%uK}Ei#18ij1p`Z6u1ax zi3n0^A8LCnZP=FxrQBfS`@)&vf-dm_kf@})wv|}C1x&IXXTH+TX!KTlNRStAz?EqH zaN!6SDmDOJozVYO0{L$`qDcRz5&&fCd7RbS7`adb%Mra_r}3t$Eyn2^IQC-8Y|1%X zmmZ#4(|PYgJp-VRGRXI?E}xc_D9{lxv)Y8R^y`D@6@&tzisXzI`$=UM6YMKTNY$Zg z+uK9(cI6qrykVxN^B(j+-M?@P_heUg23D=TU}yQ7_x!!7i%FRXXB^|*rUSJ96&Gf( zWQ+?s0cvKN7yab$oMTJlG__-P6yq@C{ErY?El(cXK|A~3@=CGJe&JF)KEc&D#C{C& zu(f_N_*mT1f)|~}ow!Ww8;8&edEqCki&~DUI&;E}>uUuyQDwdRWKR5jH8=9F`VU62 z36&@bX=tbgI))U=xGl38;2bD(+o-4Ws6g;Ai%(k;&u@=1p&4`@nRCA5-21A3kW)|= zlf1Uv&0HmvKD&H-xnEZ11c*sQ)~yTOsjVfkeJdUm_+-r$2mqYr6S+F zI!fRAlmgu61Zrz5_H<>q$*9|HZwKssL@RfW=KTgr{4rAX#TG_jxjF)< zJiSB@qg0x<^nx^0vtJz<9t(n7`lGg4Qg`HW{$OgO-HHS@0Gn>)p^{%v>*fdQ7*RWL zqKzwxyE#3Dzl}OAP3SBw&IJ@62t4hk2@u}8-1Ay>{i;dK{0oV|SE4Zrr<3NG zKktenOIADf-K3FxnUDr4z+(scGa<(uk|H(vTI%9K|FH|Qu^IT+87d54=z6QmlpNG-PEJOCCDPJ!oO1+pR+&`nHKbuJBEGV74LMQ>;;nQJ2 zeYw9!cJ@I6okqLu+2528?!V&Ut~avUhI=nU6|Nb7<_gnk@t{j)BFgNQub?dg<7O|Zs;FUftRgwH-!q%% z+tKM8J^rl=hM=_F!m8SNsWdCPDllzxgv8oB%EQXNX#V^%yBP6VLcZ{;6jhvxma&yB zFLL|2d`>0oTb-3Pd&qc}KvMU&jrF?|?^m{u*4OE-iwr7WndK`@2^Qx3ljCmCq9Sgd zEZ%J?f_l|U3k#?su@pA5^$VreqOj%xhN4{FZTU}+pQT0f*loX3LYyJBeFQKRk5*AoDeM}joALd4xhpvo?t+UpguT7MvdHsos=u3Lps9goItMrwmVCZ;xxU) zVx8Y|`V?aO5WM$IACgN#qsx zz-jPd5ntYcI`l&Q`J1sA9S~QB{7tF)-(Y$A?^smp!}#KW;%V((pg9h_@x`@Qset^N zM)s2#lgwtZ*U;2&c%DoZEwOpm4!`v}feXpkl~yO0mC=8=@jDZP`mN^#s=M$(BB zZ_f&CegmpH&bvxFuUEqF`ne`Gl0HbV?eH{zMx!GNaqVrfqRZSu2hc|}HsW(g_95dJ z{Y}^maU0&3)X=nRbN$Vl3C(Na+DnTKL-8A}?;O3U8TPWv&HW3`Ikohp7t47AZ%;{a z@%#{K&y%@Cs%J)%mO(4ccy=7=ldU)F5JK&$orcbPdGr3o+5S;_f_ll3*DgVyw> zgZKo`(_3c8#kXf-psJ&@-axkp+%ls&)En&0f!TSjqd*fr`;Vzxkb+qE+xX3CM8wKi4i^o*Eluq+y2b18{1xyv|jNMeqsa+fpA9 zQHl+dr_#9gx02{v4CUxB^10M!l}w(utvEh1CJvkM3?fR9h31O>vT*|&kRda$`>@0R zf={_^eAx1hNhhG49iy#Cz$ts2qG#PHA3)SJAop2jPt;8_4g z{+6smroNR7fa4+}a`KqYnj~v^VSoGy{-mmx+eIZ-uy1zV^YJGY6Ln(uZ#j|^72 z;qyAhm^NS5m4GBa)Ycz4{3hp=gtA<(2~dxVrz$a$Ns#VuYP>i5nUIy;8^iWjAB_(a}WyEU!)IE{%|@?Rv<8os78obugYH}aOP z$aMj21#mp9VbAT&_oCxA@aDK|n!VW{Ax{uwuyg3R-Bwb*ly-zqZ8kNl!p9(6=5R4M zoC#sGvK%0nDiM`Y$XQA%vuq}_HnLT@_1J3qUjKw3USGQu**BJ?cVW^vB z1)R3M+OoR^%?}<}f4b5nV#}14tI~hs88?`Ln1hotH@oq2Z z-CVcPK5rBx2ACpu)`r)`04WN}s^2|i_@1|Gl|45fBkCBWiEV6BOvAA4-XPVp9ZlAD zXlG4bV1motkvo3HYa*)p?CuA_RJ$+<`k^8w>Jj-Jtafd>H#RsN`&$MK?7@noK+EfK zSf`M;Uq}i+C%xVe)7fCLuM3_v`8T4T9ql|H=-twDW6e8!GCkPk0y>;_SrfzY4DZMZ9j|Cf%IaAn- zGTqP`lb_4*>+^lDX796LXn16!B?9IZaxZTQYJ!d7L%%NXLGmRZK9zcg7mwU*M+Tcx z5-QAhdtz=_fTPPASdL6^wXay~O-hol)R+;UKTLF~@=Sis)DFA3{aNsJro~pk;WbCc zbLCwVwL-z>H;HFYG5!d1&Wi(bER2~g<&d$hpSV%v`}|9`k@guv_s>q_!gi=vg&#Gj zo%f=w#~ihQeO5kue`8&K>P5k=OM5XJem*rgLfdXE``~C4L^jAIFLQUeD>)CYkrmdE~ zci0@z@o9c|=bpQgwAvVdV@_?;*Y9hMMx}m|(9hzHv&6F0Z$ma+n>U74C0&a=xMz`? zKgP7BW^=`g0sc*#Lt#(IDC(?FFVxX_yVc~y0uHDh%~$8L>EG)*P_k!`uTH&>O7{3n=7vc!8l-+;Vp812SWfZgV8b?;Gv_wNe%2?;ONl60Faob}z{2sd@>P|=GGM=>N?;F3@xtord^Y<=GJ)VkX(WB`bWo^u<{;9?RyP!s|V4a-;BS}lALao>1edO(x( z+TZf|V3+>BD*)I^5TtV_6iSO=7rqy#gr#6ZL`YL_ z8L*MPeaH=a^Yo=h)^*`gSihAx^>QU2;Xa{2g>y8>3^^L-#T%lyY=6p5J@?%n0kE3! zWPX{%i?d0S#S+7*i->d352_hIDb_BsfHW8udv0I2Nex+w?BcuQp%)0B+$fH=QO2|-6&QNH5rOcF#l`q}5#AGyu9-W=W)A}MIQ#3g{ zy1oHFbLZ$}`V~W5vnziPF5F)_o*lXj^iY^7om2(-X_8N02KdX@7W@Ad^QTTPwP;HE z3d^=yGg$T4L)wO8fDlW8HlV(wAi;j^jX}8O+N8tlaYKAl`WP#y!y$5+$U7BQPa|%c zp~6zfYtPu{%zvwS@33z0J~UT~imTqm@$>}QJai?5o6GO2u~EqcectDhro(r<=39-f z=Mk~~_Jh0gmhHVZ*_#JIELNnrAZY}vRxUoTz_K3zjOJ@ zWz1E(Dhe$gdfIiF%xgE)X|2Huk9=Vw-^2|FvGifa zTfBz%RB(U@vHm#sYO{V%zS6?x%bFdXr?6B3x2*z;mS`F{unoXWcfUF~Tj$0J6GV-; z+AgDSe)UIh-d*5@D7Qb`OQyg3%4IGzS&0OaK9N+&Ouiun^xPNi5%QarW#f1Z>wnfJ zc49il`lE9aTn_SwpZL9apPuduT^2><|eUJ|ORc9US=Bx#aEEuVu_pWlA+nQnA-pdk^MU$1?SKi)a`aafv@pEncl5Oi{t~orpSm?yCvvJOcJt zK<3u&c|;wSF&gstb=B4IoSWU-)^hP0-RP9a&l$+t7>1vooNs;GRtyZ8|F}h$@7O8t zP$@0{05WXpaph|NK&`q(8f6UeKAXUdKUaQ*i<$2ry|5=PWpoQn04T(JpgrQ`)P0}nRZ*iNi+$SP3Z~N zhXp1f)F2*39JR}YZ5aBUQXK2hC5k*&zJaFG3s1)oTJx|i=%q_=(?FJW+nU=MV2#>xPq8~S}W$X&v(IS zovNn(-2@Hnt&7?lMTA>WAx-3!5xXohblVLXFlRM}=~1BH2_}*)P0N)Rk=uLIYlC=1 zc`%(07rk$7rcht1dXe{uH*bWxZ$Yv&rsQKGID)(+tYQaZL{vsKe1r&0F-NnsLV6gc zi6SSLh$WO&H+|cCU)qbiUgJ8Q+t1kvvvE!;!bgt_wZP6`yn(3YDMETTGlEqs&ZI7< zsa<7Oh27VQFn6f8a*gGs#;3Dig&`adDffKBeSVhwmj{g3W;)VLr27FYCWDTP%Q!qs!3Uja{bd_=xBp|z29mG7)Q`-)qx?KA@L z@Kb+8&qdMDFVNA~sH>T0OhzUAs5xcYe9d}ZH<|+kr_GEmb{|dOxm0LHCpAVXMcRqI zp;F@3O-NCdHwo}!=`bdeEuK-Io>8Li<0F_nlE#Na_U(N7k%(Gm zT>@Z~RHL4P>@gOM0sS^mf45sB;2%mQAq`#uDB{3zC z1z3FgAs0X$$R5f|{F%)Ilfu@s51m{qh}22DFc%dK!e3R&}GAMH~^6(zj>Xu`d7}@InCIvY;6gykeskQL>HZ*Nj zL=eZ`blI}903%VNymjOp7T@ms&=v2Hfz=lLsfLCUV*%RHA<5kAcN%FLB4R8+AJ?oA zTPeQ!hq@7MmH0dd+KVs6vmnKzR|K_N9_@8i3b)I9bkfi4LrIinh>Iuv$JZpS0x6?n ztYYu8!Zt4u8x;D;igQSY3?MiOC@`a<*E~UONW32)83Vjilh=6;1v9SSp2`_RO;(|M;4HiJe@C}<2?H%Pwdul@Si{m zjftwy-~3X=vXhJ=T4S z1v6%{)o`qMuF&WV=J)XhNFPZ!BBAnuw!z%8SX(13V*#0_?T?A&><#;}mdfeo%aaK{e)KX?Drq&W>@u0xm$TC{hT1E$QL=BHt`C-&rNW!rnRMp&>bj_>>Qt2M#F zJ6*Il{EuA2B5k@LbCtm4`e%dr2GS4XAuIrwNyvxKClsTn$1~EBR%l9bA&`P*`f^1F zI-HVzt;m+|I5*`ys~?Az+B#OYyZ6CYec>k@)*;kmZpjezI<||bloXd~XHtaTcOdD; z zUBx4n{>_?p{(o{B>~o>3U)%eG%fRNNAg-1g7in|-D~W-rWO;`ZL45)WMXH52eOzU9 zdG8>(%ucsNC=AwDFgxmxskr*XeqTf*E^csyCKZW9{(?m0Q)9w^@O}!#QY0^k!jEYD zST~<+z9U18M)CLp#<=u*0E!pBSh$JqX}IlO?P~O~OAgPfl>B^(8WRL#>LB#h$As`e zMe>|ct~ngMQ`uR?qJ$4C2*K>6S36R)KDc5^1kwGx$3lcbLH z3NU2Ti(-IO-d1s`=rl*Stv8~~%wK1m!x<%h_|LY!NU~?Uh(C`s1cb_WFhgr)`Qk{X zfJw>nB6{PRho8nhO$KG9U5kG>j>V^AL8$$Ozajo3Ol>+Qln~Nt_yCG2Ox=7)UpjA` z#qg|ll%&-4P5k~-KW+~S&G_(k;&BL(;%Zhs{p6k|uD{!FfkRmHL-GPMmV zl>wS_$8H=6425E&IP38G^2nCFn6WUR{m;!>E42Fw10?-jH_LxjHjpQKR}Q?@kcN_( z*6osM2Fo6_RHC08lB`TxK|j)eqfC)qprfl%pZ#f#m($RmO7c@Iv7*CiMY(W?X12y@ z@Jt#7dX$K`VVDjh`GcGsZZljff0RLhu{3Hzex4ax^kcu0A=(SzM|M&{5zuAyJ0bzO z=^H|B=;kdrmS*wx@th-&Q(a?;L7v>1veqq&o&>#5x034z-Vxjed3^4Q@$ zK^lPtA4Fs*r6qEUCs|6Q0#z73(cy{8V8s>vD?B|kxgMV|BXpF-H49%CwqgmzU_C#L zF}j$I@%P2kLjVaJM}z4RQ$N9$1^MP3_8+`#bZLyxvLNgdM~km2|A`__6(tPlLwbRH zPDbi32QJVs?AfYD`mmtUwz999k=jVQsUq<%_; zrjUym8TJO@WI8kZj<9a|TpQtdNs`23H z!~44Vn4r~xPp>F*%^+o_`W#m4OP`u5`yqKOgBmyz1FH&{u_fYdB55&>gCRBF4)G2E zk4W+CZ16yF1h@~x1<*aUSjlXb2{eZ3Xsj*rEiWnJa{~)4`%8L1Nz)IMB+W87uZY$y zEUzXfi@8uRNF)`Pouh$MAA>yd!O z2#BI)3r7hOw)a(Gf)V)|-q%t6L9zZ`k)fluE7Oe=t?=cfF}Uw1eMiE_f1{<9!Z$A` znDf0ey}%JO!^Og90w40Bbm0dwkYz#~gYxYCC^*jJ!3BW)u3xK5Unh{E^NxVkoNeOm zuhVOb57a7Dm|lqC%SqBm%GZ;vpu2|XM)p4&^XiNnOLftP$}-f;OCOJHcr zVpY^#EqCXsk~f4mYPRk9wSjep`0b9btRsB>v?=_vzP21q+WiEndg^=0T#YRiL%tF; zJMekTeoy)e>N?}9Qh-(dg>tj)rc+-GLt$ zFBT33^Xo|qW)zDJPCp42#DBa+Ke3_C{9<&GxKUj8*|GW0G+v2#61(&JaofhrN8Ndc z-M9}tPsK2xTjH+gMchSUR8>juu|6aP&50&CJr!DuDy!t^4OGw1->o^)D|;q|YayC7JFEoyVZ?yYq1xDooA`8lvkfRa@xHp>Lk%J-^m ztNG=FdHk0m%?3mB(>~N{GY*xD$Ewb;Jmv}tj3{I^0jD?1=iQ}<=93TRPP3W=PLuE< z5a(-iyPE*^pSa4x>3k!l`0qj>Dj>dei)ZN#O)javA$~1J7D`4{2eWw4yUEDj@cRg_ zdY;pWu}b{H16-^izihVnG|%ZGw}q|W8GEMVgRrc{w-_&dr?TOCd3t8QdE&Z!fRk17 zFSGR>35AdjieREndkzG2j1RjqkXJF1C z8dJJyHikW;sk|5_JC*8rdq{G0V_jG2M8!g&YMf?WnQyfs2DE@1 zeSAr^{@DH@0JnYw;50*q-aUogxZu7Mpr;GFbIl7T?`>%Xr#Qy6^gWvD6C0ly@<&Yp za0{Svs0YL;Ex^6EEGCAX9iBp>&}BPO94z3A8&BxnO+3Bkt+X8Z+^lqY*@wQJZaviq z^G;lgA?V3U`CQ%1fIve@U&Y2y*sqdDnLDHo>aW>zWl|rI>tC|#1Sn!|mU&VvuWLj3z#qHd{ z3da`MV!w=K^t#4cEqP{gEp_^D*`gRAZGO~X75a_bMJAKs^GDY2VZKqaSIg;Mfba1W z!}2e4fy^Bh-@~zbft%f>3!t7DwMx8F(&pz8^zHGdc@CSzS&yRe-cDCzvoZzhwPmsH zv%Ow)CKKn+-}KC3!UZMS;dAElgS%+i z6*_X5Xz*0*Q@2A&_v@_jK&I9vMhCS#J6>tZs$!olvQP-HH77gDh#kHhvUy9gd=eUt zYpu#TM@P;OVt8`E{R#u6IlkWPRle{mf|7ehSFz9Ntj|sfBvB?d!_?Py(mTC;&JXF! zxau0qVnF;JfQ(_#m6s_3#3OUP@N-J>;Nw2t8V~UeDoOoP33nkAf}=^8s)-~L)Z@^7H<}SKu}zg2nh;I>XH#kFf~UlTfH4(kzTI>c9Q0up z8{B%8@HsfD4Z3HlaD)R}!K~H&bH*nBgvM6LBOHLOSVrd9drajckDspHqFL7}xaSqL z9IeXA1|;*^a;SK~Np6Xw)6MEpn|0gjQZzMi-fO1uOD>fD&041yV$J4m*B{ z!?NYshgu(JXTxj?LM-_TV=rA^@GIG{a!FIyRpgqNyXdVE>>D#3e!GC?Vo|a^&HaJ| zAo_PYWPIbm6#vilGq;~6TaAa8An4LO_OPz4{Yp3B_%0Vm>%M4t%C-3eM4e5Umj5

vE9z)q|oY%c&(Dj3lP5|j2`eA3AWB&ts%C0r2 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∋rPb?mJr<{ln^0rR`uCJUT=qy*Py2W;zWoF)A8 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 Q1O3QKewC>BVi@p$0Ulf!00000 delta 30132 zcmY(qbyOT(@GXiUvcESPaG zhI{ujk-1|$djbQAdBPxn*GJniPp%{fJ8Rp?v)z8Md@t7eON=_)50Rjw%jo)GVz zJ&6Pj`m3B}GU!g@x&?+b{L5@~qgd-58V*ZazDd)H5 zZ^!{GDJSt-GuRsMpnveKW2l0_q(73?1NRP|!eQkYFuNQl9`9864kxkK|2r*EM*Y*Z z6%68F1qatp#?1g(fEEo!8-A%}g!N^waeq4&CN5nhf#$Si66c|VB*x!gRigse5N=|} zY84AyY33TSQWidbZc8OVtP$ha&xq$UaeNJZf=t zk)NK5lxQ`mC^VKVr6{`hp-RLp5N*&kFFk72UZxeRuvNh7oM1le%@g#{pJDWBp!*l` zl5OE4ao+IhVF0{X>%S}7%$iH6N#7?ob^8flvXe*TDhegj%_M4)2(lFZYvlAImrbMJ zUhgk+scu93$@cV@t%zBp$!TF&DkW=*LFC=>>u5SwtD6g@BEd!H(BXBV6Zp)Xy(y}D z!*+RAI{tu8&v6yB%rn#MHrb)e_P?b7wr;du^!=kUgCCXR%YS)biF!bPeNBfgY>Y4Q zfn4d7_P>(CPc9n{K7`avto5gyjsR<=G(|J4+);XQ$_NI&5DvayCozh<4E;r#Pu_b8 zi#N}cwlQmFyhA@y2QxJsa@SLKvW}x=!<$BFg%|+#0-9Lc=HLLmqwS$~olnERW1DFmly6~nN1R~6OLQTdGhijzYc(EK1qqtz8guw(DIq|3ID1xxi z8UB=G2R#Os6I~5EuGkQ5NMES++K=P&fop<*Wr?3v{Tx!`+{)JqoJrpaB5KLu=r8qV z;UutSF8mgyG#M7Pcs2YgbTv=O=5@ZR!u^(fw5+@_eJlAHy#2*+H;xPj`;hsy3j4{) z4HxVd&>#ApGWnOxqwik0*^JFA+!|dfCf3m}*(f6CHVS}`t zNxqd1cp3SswNJrv{(j85WWlDKikJRvX&3M-$n?i7M|@9=?%zf&Q>2oE83zOV2|=6H zkGC2(G$w{Of^zCq{q9bf(=C1(i14hCVHu~Y7bbn-`w;PxRk@PYXqsaRWOXgzMroIQ|lz2{Q>_+Bm{-zop1>2R77)r+&NXLL4$4K*@{Z*R^_l-i7 zb>z=?G71kX=`9d&_mKCQReQOTw`xGQ^EkMI|6kF)`kNk}-)ZIt2u++$f~(PG8#3b7 zu2+>edafTk#^2Vac7Yw2*zJ*W-E*qRyf@X_5U3s(G zc|&!cAw+7yViTMshRmer_QD-9mx_`+mQO4#rO=p+nn}Q=24I)l#_~S={x1Qd3=fjM z0kYUzmr5~Bwn-xoa1x{&E1O0N_Fu8jcUBy+DouaitM`ws%~tCsQQ zOfb$m6RcL^G9HbQ-I)%$Hhxdd!Beeg5EPwD_`(sh*&S*q=8-W@jk^yA!ct=H@p+Ru zFQYfh&(LTIO<>(<9nxCK_K-8$IMxhCBfxlO+0KwQU-k={M4%j|+gpt@!nG;y)rhlZ zTJ5X!x;vc#y`Gd-68xxuJU->K{*8dVt$NaRaHKSa+oGvw=s+vc1ECO9?3|=mR&0(t z{RE8$LXWei>O&6quMaPMvRbHa+vUMghNR;x2PsPE72u4`9?m`^>4ulsfKgA1Cfz^1 zhRrTIIZmdZLG@EN=p@GYUb4npjD5Pl*ap}R*zqy5)o|+{!PSUx5F;81|C>7Nf5uYw zRV{f%Q@_t+j4MIf8u27Wj6PRM=}1hCLK2zA&`^XEY!3!I|CraNNnI&5Yv7N6(N-f% za8&pJ22io3FV?WpP9aP7dkE27Tm~Er^r2eE1#@-=Hv|VqCC2N5mBYIYsX3vovDYo8 zK4wJcI)aKd^`CuZtbVaGU(GFQ%PN8{QykcjBq7hRl8%3mcPyOWYF`w{*GqmcJcvf4 zBwUFXcN-2`Ma z+)ER$sVM%|^@O#hti)UX$i?fQU)bCUC!Jp94bLC&&ai&2De#3`9DVl7F->09HPbbz zD()dTPn-!k?g}ua89ZkFLefHS-RVEh!NIPogVkVFx_&_zrso~1QY3$GERxVp}FV;cpV-s(95y8YsTdzBg_O4&gQuDuFLpv? ziedG-Bh`VMpt{{t{3jWP44TFIcX%s z692tD>;)gtiYP|1XiC0lnqNx4hNx8EoMM@W@i?1GVzZnrVxR2~uObyw*w(9%B_wWT zgnTb2!sz{^R>fO~pjcJJ2X@fyt5z3tcGcUvf>;K(wh7~Rg}7c{5~WxMo_5s*x}x#K8~ z2j*QxXK;d}Zk}wfttVS8^xI=I#tk_xG$LNtbdACjV$Od2Y1^av=iDDpIAJIlTqGNC z$n7U%`-~QHD9Yfj7oo`Z^ksxXj8(fi2-o6|pyPtT|0=yi7oo0GVTG>LzPdQ2Z}d3E=hx^x69-8)U3`Xsa>u8aoiRU*WLp9T7(@6*T~T z@UWniU_H2z%HWWJne)J%WCM6(Bu<-dck(8Yyz?U{QiR?Gf$lTU%}gI&;7H(xDhcW1 z7vaZkCOMDo)Qs-?Sy;#O1}nRsR6M2pI)H62aBo<5re5W9k)O4`H)*vW;A<*lS}pWm zsCG&TN15qyrdh>jO@-qBgr?Bd*l`b)I%dA3K&`M}6wCy+9el8*(r!-D2ixj(mWQCF z-x?FO*^$MdWAZfSGUBlhIG$RCU5(ybIL^x{)aQ0I$$Ei8OLdP$M3i}>iEirn;Cj#A zRLTT%?r?LgExIU_Zq3Z%Vua1}xNR=gZ*A+@tj`^6lE;w59*YWrSAU#S&h%~lSPxopyL<3`@FuKOxrzwS8g6`nWzO4nu0 zAmq1eNtEhFFa^}bz3RVb8r~ZHstx|q`uUdD;XbED;Qx+Md7MXbwSPcedsL##_&jFg zTVrT;nkwLX!+jz6Nb~I^{VFW)?!hdJ-ScbMIcEg6pqBK)6l9GznI$}}#BXB_JASua zBu%rVTm8-?;uHsfQu(L{j+f_lyR^gc{rIVmWzm7luBGhs{TI(U7V@SG;*`D%9>!!x zC1!)}?f%-hK|>_A19z0(WSfAQk0YpFIIO!9$#U85CGusP>9F;DV;J5Ej55DHBs_9? zT8OQy?I>!TR8pb7S=n7U^PkM3rAF&ygpD{iFj=WwnR<^tdI6$!LOQ0v=QzFDzWp^e`g0w8FU>S6txpjx%K4sT8x| zziL3NWLrknF3Zm?pl@?6R7I&f7Ey{Pkp2{xCPPb<5jRZ`X$|(%FkfMPn{)YZR%2Qg zbA+}6D+fSRLAumn8K_6C|3cYH1nQK`Mn|P(QmX<=Lbjrj8O}^>@k-GPLJh zH?~1huB^Y*@57bV3#tm+N!1e!*-^+jqJc{T4K_IiWgE#l%y>teTwQap_V|2mp^1U?k>heq zzU%g~Gk<7?`Z%PB#*mo0hN#yzhXm&TSV@6j2Sf`~4Ej0vj=+3=Q&&%CfxwhX%!m~N zqAM5tCtFS#=GQ5jGD*}G0u(D_5s3YgmX5OfDvOE#%6N26+pXjw$y;lrY!fO+te+jc z&A!W1C5K-RHD6NhN9>-Zh?R`Wg6b5eLRZw9(Ox!>6sJut;!RzqZD|?xOvf;=e%*Su zpG=v6N4@pz8@b4qEPd1Lu+?`c?|1+-%sspBq5H;zMN+Um*3^4`ra-UrU-r3Evmt@t zb-#b@b8-nqZ}6wo`Iuj|KTMC3PwY|8h9vW8vV~?tQAH!t2D9g9*une@M2FKD54~-1 z(rIMTFT(M1uC!H(@^30}=S37k84f9S+dEuc%zan$+%h4mWJvNS>X^p&;w^yVBHBK0 zN9NmK4prEbTJvDg^pv}Fy}D}s#66BQG;y*ha2g3JI8^ve8ZN6@Q)muH?u8X)*}olp zLOm|K!hI%lMk?@gY%9sZmIUtQ`5eu@Y`d+rUZ*{JUcMUWjQQ99x7Qt^r}6@c<-|Ze zrY`tfi!sJ6pgjWK9=m@4OkrKtgj3`X27SPF86p?`2>42&uvY)N6klntV#7ImePjDJ_m5Bw5l(TIf^pi0`(J= zUfB)Z3jX?1Rcl6VDwad16PFqV0p(b$np#7D{vtbl^5#m|G&@}(DX7eBA2psT?1c*q z73@!~TJT3L+R1`zoF8}oEZ0<0A8}HlzWbsZ2#=*cUm@PuY$Ywf?4Z%K#CbDYJXWfs zBRYRXX0hzv5e(49%~ODa(}9z|kkr=x025pGjL#nK^1UTfR|@%J7_MTWJ)siC>`5^R z-Us%{^g!Xi$H+Dw#;1+fYVezZGY8_2xTZoxl2RFc%{%Z=BWAm& zY_KyPNB&l16a%~WWcb&9T;63#nXa$|UnWq;4;le(v8xZ`+^4)AKH6^%X76dY%Q|fl z)a`cG4)8q~E!m(&m6{gVYs_shqZaKCT4S*t@79aCubV8c!}6^T_WbES?DUcGJBe=v zwT6KVrv1a#WC5N}X|}dy zk2{LYGZcC_V_CSHw((A=Cer_PKA~F&a#P7)7qnN;EMG0qNq-~!W^A7Uwe6}`T)&W7 z=O92pdNdDP)i#k|#v&|}T`a})x5pQSuDJ-0f@wo{rBF9&>2f>LqzJ;WdHCy*DZt|L z9gD5_*LhuI?z^*AO`9EEUEXg70e~iQ&0a=!ibN1B2}zE?<;&j06y6yZI9fPfj4Nm0)5g{#-A?OW%(R)VvDw2mxni)y#zSpB#*S?rT z7HZUzanjZOe!h~RHx?bZMNgR!fT3g(4lF;k1r;i=m3qSPLWZp^(c=ydKPttHb>044 zMEiS`#ob3X#8;WaI~ghGmjd#3TMOxK-KHh>o1MQ&Uk^;3hP1+5{t~+%v{`IgEG(AU zDIX?BT05?e0fn@5`}d=x z`?XYl@99Y>m>;iQ}18KZNYD6uwp}9q;aE_SSqngV9^9%ubOkCN!FXA)e+;#iUDuxzE20 ztLCzx7O|jyABz;<17T(w0B%MWIM?+1@s^AJj}@g(k**vGkzzQkw)}NEZeB$`_wS#Cx18GrSa7q-DdIu0Wk}HDkNgzz@Y)agRZ$?pT_xhggoo3 z7DaAaZ9zhMj1i9R!yRnLwcg8;y$0E9(9WGW3AzGV1Rsm$IWs5OHI#+W9p2VDfCliJ z8%%or_NPv)7c$P2x;uY zON7%Y(cz1$0AB!FzMQJCVPY_!0|y2wb``4|TuO4g-)wubm&7kD5kAga`ymP6?allx z8Fsu9+GrnJt2h59Ok}{Rdd7c68bIq!z_iyhO+ZT$PQn&(G{%7dUjM-!GHLZq6chdp z^U&+jdVD7~cyALl(IGrg+;^>46=BvkAup?ov`Z9zPH+r3 zu$6pkM`{jq5d@YTq?JwmQ;X4QR(Xc9AKZJ#(u(P?s~_ySrRU!-rT#z*yA98}b}kGu zBq1V_--ZE_uuAQJvV+U3JpakWrz%h$$}XIJ$qPJ_&Rgcd@LZ7tGxttcuvni>o<7qT zt+SR^N0N0%=yNyY2s9NJjrvYOo(DfjMy9Pvg^M|3Y|LfC!TDGSDm@ueZCov&Y2?3V zYvX9Wu#X~{o=MEzrp76mjR+dmL5t7=W90EA5n4+KiHg}SfNgyWH{DyNtV))h?E zCcE2t@?&diKSh47SRMDuV})lW7+E8#^4zQ4hUJ4gCP)w=Wh1B4T9^PLYxvA4!Biwu z>N++`%R_~69H_xSZTb*)WWle}|8l@{gRA4#Y&!wAmMAQv7mreo>mcmv2wKsM_1A4`@n0gK0Ub>tc+9qX{Xmd}`({H8{@o zR$@Fbz*N4r6KssCx6(}d2}A`uHYT+gNGg26k)*c6x06Fi2;BW)JQ2M^fJ$m(8JFqg zfAeVl$MTaGX1pZ#38nGt*#aPZ5kgfb1?z}{yhjsbI1!!O0%EXSdiFrElRv0z@%}$C z#8^&>dRUYqX!anu*Lt%2&ig#d4&o|NvR$Cs5or(0x`D)9*)N7aWWYfU&D8R^NISAc zw^Oj!$L&C+6_funTm1vBf>j__XAt;fwi}LvE?~`916;2ShDyXg-&MJ^?w6EWt$;+B z5-aPu&52iEmi+Huxc$5JSg%;QOh;mqIh4O_ zCCNb>qn;KELaFR!iTP_Q&`l|O;k0|pe*Hc2`{!l&0{F%MwJ#B{7riZuvC;Ehd$IK> zmhDF{{lVV?3ALV@(;j|A+uXvdwJL*Q2Q1(Z2uw?DwpfQ4l|LKbPwD!+xOO}j3^=(0 zE0wxlyNH{gMp}`?&0F%HdfR2E)-c*XC{9%1)s`(+|K<6&hMnX%(|(o(+&J8nYGsPO zkJ6`e@`9d*bCyw%E3dV0rr|JnK}f3;MsWq^(?r@B;#`J1=TxrO|SdHei?(3t-CYV`lnCKtEE zarc9dVsXlM)MZXA!RdU|?6;7LWP=;vkcvpaR!$X1&YRDUY-M9VrxA~oU!k$?< z%yr*cSCC+F^>!p%Vvo4(w9IYw8KKKz17yG2vKTbg+j#Fp+UW?^e1(mnL1> zUGRO(I7*2_W^9iKJrRA}x4BCh%?tjo%i)yht{KrVzBE`23>T zIXgTT2{_RC=5hO1)j4|gPwkU>hJT2A^=7&#uShs&XwtQz4mxDl3s{$}uoiSQVgWit9wDa=Y ze41%(A`{lY%8s0i6i zr>w+y!{S@j>-|2>IYWd@6G!zqOQcVI7(}9+W-an^Xmp>yu-ZddiF#(>aw{wgfOqWnqQze@?dwQ5l~$PAc{Sw1yPW z4IywqdO%g0{!Mg9KovZWHrJlXyL?Yl0UlhW(MK6f!Yul?Vh};{--P+-H@tq@c}ud>unoJB|GQVrP~y7OXL^j#>e}lVtv?X?!%$Me+%xB;{=yN)XJ4>Nf4%1 z$bz#osnNZ?qJUkfI_H|jM&z4>uaG{ek^+p2TACM*qT4hrIlgT$aX_{Lo6DZN`NT?? z5kf?rOd3<1&Ek79(R}(JzZ8-SUe>(-WjT~sBRbxn30)24uQljn-plFUEbDZ!(5ZC*MXjM5 zZrIinm%2kGr9%Pc0NMaHyyGNYv*UT7#qGzrOE@*hR~d11i7x%7_*-KCXuAo?v2%wX z6(b^czae$x!91J@pLuj5IC*BNPO(y8NMdtJXddZ{E$##I8qRZku@if<0cxY($;D#{k3QB6`J;X@%M=lXsZ3hEjZ@2 z*m@dpDx5HqmDd@ z$)kqIyuXRob?edM=fAZs&@#4pHL%m{*Q@RjXXT4gYeIc24u;zHs1GPgdP4~=&$?!1 zp3W;e$0RfuFh@5d9I*}ps8;gQz(&i`medKgIK~-nZMzn&pv@)OEXU>xNbspz#^;HJ6}4@dkf3JHqG)j8 z>YjHqsE7oldroOLcOftiFB zWpuA}aYuIdo2PX~knVlOv1dKV9|8jRkbD+(hia?)pYymY3}47!u?aZ<0N|D%T{QK>Q(^}~@w3dPX+JPn-RA8<|>%LDWJJjEb zEv2(Mp)C$Xe4`1Dx^4h{M4}p( zqE=2XWQS237_Q1H3cx*@9D2Td@(UOI~ zsASu)D$KRuM=aN7KDtP@dWbDXf&(0qo90L)^~v6K#t=d ze41r`mUL%U%hsw_hcNlAz&UQ(yo1O5^jvOUx2d7u2l}5OOHcxQm(_nnmG`QW=u&Xt1r{3OXif zzvsYJd5Z+W<&}|_;U(CTXuDFZ2rJ56QH~bD$fzPxXgPFZVL}#|S1OI2F5?cLP*jiI zxqDejc!RGhPlQ4z7{zM_i<6PWBO*`$V#XT1*=s8QCoOh7$~Hd*VxzYZS|A_=NatEw zi67RfoTcn24?R#H*L1*>8HN-JW@P4?d_@=Zb>D5S5v%&MFY~Q@h$5yZ!Q->ztk#u< zYp9|sz}-r!5Vd86-6Z*7HTjN1{>bGS)ON0s-_L-nl|i*0Q4O z1iLh(n|8m3&M0VAh3}PJCE{8`yP&@c-e@F}Y?QaDg9Nlf8By<7A~=>KIG5W{&f3ub z39&ff#KDzPhv0^c$7YT zF|q(#HH-6}T&f7bGJN|ZVTRr{dTsyms_isgxJ8+?L5@_JAzxx{YsB92Kj($@0%&46|MF?<=(bS% zI`cK=ra879*P>$V(4W|3?-8nsH=%;+3b{($se#9p9T5mTF)OwIqG|!<@yV-d5#kse zCG3T)UC~Fj{L_}x0d*;$t>JelD9z7D6BrxiHqH?O7BB`^rcxmOp^MP%@M6b%!+I-= zHVuprcMD|YlPb2B;7@MfEB)EJ z?jSA2vj)_^My<`ey1_W=NH2i|lQd6ZN;E|osD-A=wf{yh$?ml1`0Ki*|HUu!(UNJg z!w@a>BId4Gaw!(g*8B~{I$lNDlLu4(KM|Cx;Ie?A-~Cxj3b&1``jesEj&G`&BgfHh z$dLjjxdQcK*V|O*mt%C8uvrs4(BDRyu^XPi#Y?y>?&t2Cfw0ET{TWi5%bVX4iC=44 zxEe!A{v|Ju)Eo{i--b+0_Pfut>m_kG37%#h*>atjX2%Y@*RDUEQckpHz902!=I5lB zn5rwtX)pld%2`e7?HbS@*H$-e2~ z0H#!vn=5;|ytl3`jBfH_z-Xk`Uwey6kiH8VRQ|_wRY98v1&M6+-AN{Q0Qy_EGn&H* zJ-V1Tc@|j3f9TN&qG#GgL7)g-{W%HL?tCj7QZs$ok9;P4^4L#+vp~L}hl6R-6}rfS zVeT=Amn)dufEQ-LpTO0RFH*Bs7zzLWXI8dhGc8czawzm9e21}8%&lms$wtAA?iB;Y z?B8X#6S&%$6Nar>m=Zy411c>`camj3XnPofVTy->&e%(b_`S#JTtt)>f7Ad6KmKr| z(@c6sGH6&7P*0o>h=Uw^2{NJMTglfT9{K<0ZfY&s{}CR?(UCY}oH*?2@54if`GJo#4=QI0|} z4fMMfkV$|>j+1XyLu!d9J;nBN05f96CiBBOSYCp7)Cg30M~KbF1YX7#FXuR-R3PzV z4=*o*S@Dn=9_InZqYmHB|(ZAp%>m1B8+a!%*dQa7%47 zzc;wemZD{57pB6>3vy*Sm2$ESe^O&wzK3Beip#L{eIuIG0FiZ7ku%2;Z1?1-FzzVF2+Cab$Y{j!tC*H z(YtRSR5D7aIB`cJbNA-eJI5V$dhY!4IkF`QPS<{tfwH=6G3aS4C-`nF_IbD%E z`q<*A$pA$Tzbk^Dn@-ORwJw3Qe4D|{o+jSE?v4QkF6Dnho9&o|AZDXA4F%$WrL)HF zrWtX#zhpQ*+>(~dG^8eAg|wOw*KZ&fowE{DkBM{&Nh{0h+1?j~=hxVL>eg`pfdWhc z4AkK?xRxyNd(ACMPZsolLcLB{R}INkkEygBxD=ef6y>WZaViqP+wvGW%jO{xn39wJ zoMxZnkSb=q6Q<9Q*y-7js?Bd=VFyI!3l$a8|Yc&4SmQ`YptH3Lx1F2AaqW(i`~fK zSSw#qRD#6;nS*Mta%G>rpQ)_Zptb#9V(W3)YT}su&nfIT9B}aSVZL77zhhsIF3G}& zn0)eWm$e4j${3F!LjZBV=%Bti!!3AD|5m;t^{Rev`3uXDK$qGLU9u&qm?bwvgep zg=4Ax!*OI7Guq1t`y6Ibp=Pz&ig;@%qG#xcCBfxJVDtP1y9>u%InLD1ahnb5gT2-H>pWslPdanNn~GCi)T5U9hF<0koc(R`J{`a@tm#>$$WP^H)bv zPHl|G(s3#;uqcawDvnn#(J!Q_5}J$N3F6y{WEc)1fLA8O-QBqLR&qb<`~OPyoYsJc zxVkee)z*XZ&gihR;g<8oE$9}AR30;W8;|)KZy&iqGL!FLaAt&J@eMM!<_5K+{97!M zIMs|+l1CTCl~Av^ZHJXn*bzVbYp=Uus=)FDVRv}B9(09b_=#s=sIUJVQwr#2{A3u4 zZ3kKcXzmnw2*z$G0X;VDQCCO;#TE_N)bYA)7{bvqV(Xw9~swB=3Q<`RelIQQAmcJW)bc}I)@5gu_2n13c{Pnh2%BB2xd`CTrx^KP;>NP|TZGI$J z^7oSZMDGo~T=5+~!o1VTD($wQDigPAzh zqA4XPS#EXKaDn2{TFVJ6^GQGc{P2zQsJ=4ispp}a!4%n+-I*mFm+m(&20m-N*4=*G z4t9WatsG!m8M!CWbt@2;raIK9Jur`9oEG&~7LWQ~$q<FoA!LbU&tz%C*l>I6DjmNL8ednJZ)U+7|{fD5{(WGPnnj#gU@y_TcSmM547gz2h-S{NdQQYL#5GG zKtAIqf9!LnEeA%=MV|wMSWSqWPqDAi%qqP*K1;xC5J5sZ$Tf~>OH)0u?7&d8y_GKJ z7>2E54<;3uW7Pf=mz^R8JQ1sXdKVqUhia-pSC37Cj&s25`=qr=q47aQL^s;4-PKXB zSxv)z|Kbc6l25PMnC1<@0cy!KD1NLY>gHM zwe#GN*(MD`9}VBOjYGd4f%JzAx|1if_a?@{h~9sJ?>HPa#6cuAr5&LLTo=p7nXcpW zdL{SJPxdR4Jw4OV#)5P#Ihak}I4sPu@cAyaZS0F2l@>mR_;n02fJ`ou0fjWTei%;k z??Pnw>4WKO14!cCI^SkU+Onh7rD{`m)0WkhSA$&>CFs{kb4~yf0lEp93d2q zG*3Mld2S$oyqy#iwvWwS`i)^yPa0OU1kZsbt0k@2oS{ZK3p|OTVO6ar#&<8B3u94Q z_La}ARQ^c zlkl*cw#OK=#btR@|L=rF)g@%H`s$J6j(S}nNs;}BVyf?`+kPK(eufgA3+aZ4|CPBY zni!%-4Wt~ErM=#4w{KnV>^Ay(9c8NPBM`Nq2J9RO3e#X6-SSQDtR4vs+BLI2J_-G? zg2}Z6et{ZffP3(~+P~Lkt8s0XWxHw)<*?wL3PhCum+ed4I+I9Dlhj|M$s@=dCIpb$ zC5&E>6zaWoPH33}Op1P)&9t2-E}1pirLndpidIK8`)g(=dps2=t-Ftk?0aR-KLv4D ze5<2y{>(6_?_3<312QK9R4#?N5Q{EZe*s~We32p+>+n6)%{jqKX7*A19?$S#nckmc zzOA+(GDNLHMi}$ z|6wjc>yqZ%4$X&SVh25SB>nYabGP$p)GfT^8ZrzhS_Y&p`f|)KVrfK8NP?Ek#FloL z9G*p|fBtVX0We{(^~z}W_Hn;!qSf3P8jabw?t4NSV&YU$7DNxP`#KKW@o>+S$!_Oi zFYvCIcMYYkCK4}Rtn4k@B22hkU)Vno7Q?C={TL9F&tF%kH$PtUF9fLvm{D*quQ$lvV%SFQGlLD6g`9<;=|2BB1)6i z=exev-RtJ3G0OS~3S<6u=9MosMTqIcZm`*6fqY?j24HH*POJ3%?=vXA4TjLf8!l+= zldTyil5@--zh4;-t3F?$qR7}D=fy1HpoFJsZw{|O0(Q&Mt%Wqjk8_QNP5FkI!GieU z)}!@&T%E+G#I<8}l2T4;G0`jz`p>*wSH~=#7tBcOuiifYdN0*o_Q1D&vko6Y`((aTtcscvHJe#eK21YH?~3D zS&z9A;0g4i6Y1V??|8Ps$Sy&hBp(%U(+OD4YF_XHR6lvN=Gk9rwNzCl4yTx=EY26d z>vwn|2b=3l(8rrr(_(nO1hM@C#?8W$DToEVV4<_=!7g}i6u@+^2m5^MpNlzD@PlxF zXf;vx!hD-prVGwW%|@NxJ@3)bCBwy>-b42WME}zTWoF*w&epFTipH{ME@5@y69Vfg z0zGtblsJ{kzQ9ntDSz2c_zEe)5y!w=rzci-I_yrkVo$Q>M$sNqfM?ZPFb58z|4w+y z0n!#;!#7m%yNMz6bES{&%VuzVCEk@_tutJ%$4YpZ$Igz!(r^ z@IE_Q{dCu!+BxNU!Fx^;n&ks%j}^8_aw)i`SKU5Wcq%3Rn<@3axLln7**#`t(8~ z4&T;*=1QLZ!D6jVk=v%V8gPQPSDGsC!(j71?_iLi)8TwhyY4%R$mny@DQ|X>xEdWj z#FQq{Db)X=AuyW!?KX;UYi~bU1?BL*wV3TS#_O$v6l2&LAK$(`>Cr(b3;>y;YMTXy z67CQGGRcy$x#N%6fn3+qdAjCX*S@DCmG}zTy(_Sn1D(}=lO0o7Xpw_XnriYnf)VAh zd|;p|U&&M?82e6*c<^UK%K2~BRK4d0C&&ywQA{)r`l3jhFS@?-sxj^Fm-HJgkZQ@P zKC>e0eh+BA?Y`uJH*i&e@f>*RbVmk@OV0IMi?uA>)iehhLy=?8)yP|$8#x=1K+dW& zLT@B}WNdq&RXgI*z^S~+KicM|Ic8p3H{^p_t)r{cn0wuq6j-fJU2>utRwHQ^k8C$6 zhfQbBFuhMxx~r43d%@Ri4Ibm0Z$$5V*S|VX$Oydr#j8Vo429@{vi?d7!qM^e57DH1 zi-JRV&yzU4*3S~UXVU{wd%riI(bFFj|Ln=Xw}231#m`e_ut}cR5k;4ij?KX{@?gTy zVhg^{^I}y+tFbk`dC5N<#HBD$4+{pnAAOii?i@ID9kr}w=QZ`tOPjY7>N?JM@%yht zw47n-fA~7Nv}FNH4r#$mI6F1vpQF|I9$?HhBpDe~{6R|&d?{%zIi?efqwK8@I6QG6 zG}wH0xBBi4Iynxkd%$x(Q^O{LMvSkiMVOi{W)R3e-Pe4IPZP?Z~*Z+oX8l>i_M; zCK#(Cyx0Ma%{P0IO(=gJrd&=!c1r5{XYcJ+2j-B|#`TUS_KzRC58}DH|I}*Ld*1oC zJHKX{&`jPsK5R^x;7Fd9Yb72X+^5X~~%@Z7!FZ3lBPD)Sl^$ z0KK9`pXGrdrFRb(wbVd>NF$txBpwx7U@eW+`8?HurG~UpMq29qWf~n5Qb{H#_!n43 zQu-~(X#kJLKx#|@jS)imY2b52$fTiG^?7*#)E8&!&mz$Fe}%|>bWZvv3IPZA?>z!j zy!D&m9@$BwQ>UjyBy?@=S8xQizCzl5RBbpJX;J~6_wlr@*UMM|i`6t9i^GtR{OreD zo|WI7OWupCKGiF)X&#-YUX;2jJ+)Adu#fPbF`q}PAH*>Mr-)0#d}38lXyOYJ@X!6y z06bqwG_rPm7P-3TS0A{*23`@ZVT9Y{u}tc+?t= zV&{7;=@M*Ij)xkTyZ3fpb+z6$utNUElqp%h(lLffmAYn2hjLbXclZ~kI=x@YKExv( z)?X>ET|=vNpB&x}4x()@V_0ixMak^00P_dzz$n6>I zr4)fGT67Bn&R8Ad|EI9G4349Tf&?vQCX2yhu$b9mX31h^X0%!sGlL~rU@iT*Pk^P{V}Dqp^QS#RETr{DKm$v*D>us^<&X76;7dQop=oLsGD zFfqP8Pinu9i%t$6o(~6FpXps^wR!v8?Gp_YtqHEaPSyL}&CIskPlUad#@bYx*%h!+ zr~P<1&!algpN0ms-Y+4}Ur$3;Un5+3_m*|#QXBB%VosQOaVCRq9A5FSAfFIdoW0%l zbmr~Wdbi*dN8jp%?an!b{O4Kqe!PgS8eUQ;6e&22CjR^AFu{+j&>Jn|zF{s)$I9e= zLu$Sm-~yg|Y8V|}=S7uH5Xv)&0vn@>nkGv7l4&W z?2o@B-rrP+-9uzW9xJY#k7+TnuL(s%e$Ovxvp9}aY^8BcAuG~u92D?-7h9gf9;mj> z9!|qC4_`uIpV$94_`Fl$1#bqtYy@IbY+=`!%wSSuQ(*{ zrZhS~pmg+MtEVj&RJ*AcvkSZ<#~*~Q{omWqVLGobvwqcuV$RegB36SBAU_#)#teQO z2-5>=4S6fLh$VhIrU%!Hkqb-7#9>J~$Dpe(2iI>esW%&4!yiU0_F*sCehHPnK96gF z{@)p_w99Ta=Dpa-jMb5ai)XB6;aj&VjD@?$QHE;t4oA+e_s!;()9`xGO~|Y3_{0#U z=K-EBM`)1W%g7Dr?Qikpz~qPezq13wjXnUhu-22*tf%(08p^Ldgo%&O&+E6?P|_;2 zH1jH~avzV4$12w+-Y=%AFJ2fBZJwEkjhFn_x)SnU8HRWCZWi;clrxNI>zeVeA@9+X znaic$<)uvKm*oDDk7goQNKua;kW{|#knnYICZeyp-J|b3W$?ebtH)S}Z$2_ZoMr$D z&2N3Ok&6fz40Kxs;uXmONf@IZ>_GFtu*RA43%Ql|UMR7|uFcf^*`bN58gZ+RXj^G$ z)!XxtR^*VzpSAZxM4{L0UeIPs(Brz;@o>x+FXixcUX&i zuV_W~>>rgY!U;W^U{{gV$I~E*I3HlT549tgaS}=W_;ii;v|5KHbt*=+(o$X=u7|SR zN}Lr8NsmSF<@7K%wOTJ^?RARX{4PLXa!2<;V0e~We|XEa_^PM1GDs?t3q#*@$vF!2 zd=D1|elq)uW9CGHmcxP|Z&O_<3=q9Q$A;hwJt^;w)mI^F8H~R=_3lcsdH{+h3u9&_ z@eeb32B|98TE-P)UoGJz-B-F;bw_A+d@{DeFmsv?BrpW{?JbkP>9QSwWZGbo&a-+n;Qnn&Lh!c!(VAeu?Yj+A3;$k5nfmpGy8_dl~YLHy4k+=nBv^ zF?)6skyJBF&31xs7JB6Pgkt5|0%Uv=YmEvwwyA2D)SbPp3*a9xB#mCWr&L7 z5X0f^WbLq$?pAYy|A23)Z_GJHm()A@!odG%N)pK-lQCk|`3!DaBjv=wmrToODMC)V zVCHqRvn%=RVWd&l3)p>{^V<}Z!o{eldjBl^wBwo_8*`aVx$=@E@VM-<-h8=B5s0&* zS`p(mfgtBZ6!!r-Kp4@!5DDb}xFs@t-PAUZOXHuM{$(5at7s@35+bnLO<1~#a!x{7 zKPS30TWJa#O~u`H#U?zWWVg!|6PejrN!Tm9y&TvUR3mw7<+fy+t^Jsz6jYLVuKH3_*~$wqUFjdxoP}WrjZH9-}*8NIjFW|Tlw;{BB;4@rdO=w(%$fG{ycKr zadl3?P@uoP)LuJ~)6H%yD|n7W0tkVaje*Y54obOlhr z)i$Da?-*QCnvuDDRCu-)dow%YTPC&3c^KMWgof=Xkl2&8WZfjnI2a${VEtu%9$93x zC(F$dA?X~6)iL9O6LfYQMW5p`zdwe@>T?3R&zp_B*KO+65N@gdR@vSG(&_dn#B7CQjqa1Vz`m;f<>rxGv&rIeW_}o5flnUpc(2koLonJj{5`j4Hihn2ZHx0uqXP>9 z$*p8M9Y;~wvR`$Ae$E@!L}+vuHZY!2H!=XB7_&7d^qR5|lW~ME_cW6`;ZN+^&mp8U z{P9C~rt8t*r!|&Z3ZV&VG8xy^a~(dCcroyIuouqsjb66H-6msGAIayGbkKW+9DTyNb=qaj;rFhX0A z_ddCq1{E17qgAmn5ny3~v!$868a{g%_fvcBwkJ|B*8N zey|&4akFPAz_nYl*W%S{z>X$U@rEZ!zAjSs+Ng+Pa8l9vedLlfFTp6?&mlXEUJmr4 zX4M1FCznTS)~5O{pwa&!SU@*Nc{Xku{%shlP`<5jKWXF-j@{s0%H60Ds0!fT73FjN zh6Oj7(eps+)oQnbU+Zab9^3b~tWe>mN(<0axLGbI-d6v_0scaFkf z1$REHyj^lwJ%WAe>6JH2i>e4E zBb&Be`V(Ov5B@^V11e(~ivp_bjDi;AV7{xhk5^>4S@Fe>^_R<)cD4P|IC(uamU>Dg z3K2UXxhE7o#hpt@rDRShZE-0Ow=Z4tDB@wq5$gUtGDJlLxEno@Sp@`HC>D?x)N_>| z>YW=ntj@)pwGg!#?ZXR>X&E$kBz@UeAoZ{#;&p^Qk;TPME{dk(7_E|2?YS77*}0<8Acr?5Gop%rMtENHPqdN+hqRg%xbC=Zbcz@%bX1kg_x{qoy{5AFAIGBuYE4( z8;^wNYffl@uF>a-W}78|%Y=>x&;}bIivSM=3q#rr(~S`7{-b$K|N4XN`2!2t5k0tdBKs zp9tfN?0XSC6KZ}y?t5cb{Jtqcg7uo*y76{$T5ib&bO}8@64>Np^*J(So1FH=33~pL zzsMDSmbC}33Hh9NR4@R#3uu+Ddx15af^|!~GF&43oK?hcR@3|By}xx_Nvt1#e>HR{NDvE2@;Hqg+@&HJ*IzO!@WZc%~`SN zNp*9IHxvng9t1unStVKxN{T;XSR|_hvl8#=K#=g09CfnK?_I@wJIFKBq zsU#6bF-heVC7vR*b$+$Yae5GXE;Ho3KPps#it=?&#=ik0Z@`ZZ#(~*#Zd2Z`YIU{T zDH+;49a*3xUGEyorm~van^^6>q-qC7!koK797m&HT!tOD>cM$ArT%Y0XutZXiivbO z(NZeR?9_dIXN&rm&-8!S90ou#cDab8@Ou1}H&}0RmF}=z2N1Wqr#P7FD6i*66)o3* zdqrW?vTwrAJ9H%4pw!8){B$(jDmL}hTsQAnXv6yXZ6A&}H^q(hw=F>U_7Ir^Q#&VaGGcOaqZ;1#8m<0-%d=aCnT1Ydgq zC$#aBlXf#6*FHJuA->&4c+0U38_Iv}ec_5k@7s!W+a7c^)@z;Z9>ZPvDKMA(SH!I5a$A zk246Sh3{MylWE4Z4%&D%r!s86MP9DYa1Oy$Ht&3&c4aVZHa;y)TbNzK+hHK3Ck?|! zfPMVO29&YaGJM-09CxeuC4LZv#nX6ZS0%7z(s@Dx+pQ(LK!8G_hQ%>{u?Kq0 zhYmtqF!08{tIvm~p;V!u4m|V(PHVi`d2S9RUaxEMBCD)*O^MTTet-=KG4*ihR3AFT zQ*0@&XnY}XhPtsQ;mCHSglt@&NWA?yp(3yiUk+#LNNPji7!DtdYzbMUhQ%~a8Nt6(z zKCo@tEYR&6_j|I#upLNdN6Q&}wYDd6-sGri+r;*N36b;moAY5@p_9hc9jE%dHvV|D z!_ab@EcO&vNTZr|$1|4wuSY+p?K8N`(8ZEg*uSMYti8X4LSrfy8L_nR;A#yRe_b!q zg!%&Uu^z|H(Drw~e(HSvsiyswZ_DeYx!(Jj(nj*vMep5fM+yMz4yUWPIYmRiJzqs% z-v{EH?p2UnW(%twmiw6iux|9!2hw}$+<9}8dAu^q5qSCD3G}o1U%ps+GvSePjop)U0NTE|bqR&MH!myq!k)o7(r_5(M#er4vKNh8I)HN6<8-y=Ruv z!k>=*w|Wlm|6mw@B^dsF%-Rt6Ng287KBpmHgF91e10V{P)38nJbSqdI4jRvH^rO9d z;Emt3{c?I#%vhi6hq(|6w=@KjIs^57hun`F3XQ~836XqUOkQ5ODsIN}s-+8?QsBL> z7z_B!79ZhD$Fq(337Dl=dQb;MLJ1D4ps)6*Ze$T z_3Hh^JKq%Rq4OF#;>=i2j53#A;OcGidU5;%NVrJ_X9jWpCkO2Z;V`e~nK|1XgI_J) zqh*L#0qO-k7uc?s{S<-DZFhmma+{1+F2c-RdY)-YjwRY>L>~Nv2|R+D#JwW?2NY1E z7^;03jf?n-D@>~)vZ-6z7cY(b{BN8pflc{I!IqC|c{qW*U|j*@pD-&3vc>jEPC5_h z_I!Usc6jRJz3o?R;0`^CD0~EqfLE6z?x4DA$Ft2OOOk$D;a4=|(Se)I41UrHg$S9v zp}%nQ)OqC%^C5?E#o(5yu5lTY7w`8z5KnaHgERSfnz553xcZI8aK*r0vEGjfXTOVl zOEo-Xqh(a!hba=C|B2RE=JG^Dlm&1$*H|)t?t8DHb0VhKaVstayvvx$X9m#MSC;3D zcHY>wj<(Zwx-I1UZSGRN?jxn`-CIYFF=To=pKD#1=?~jXi&-cXfxh-t$S}3HE-fe> zM-}tD2m?I}LSC?4t>>!pqkk;PL~X@B&w8|+M0B~{Kq)<;U7a5NILO|+MFl9IPM>IQ z`lB|+Md-zTu!u04U&ge3jAg}Detzh$dQ z7Ptas9v;R`f1XC5rDv|#7~);n$keaZA~97q)}Lo;kI4J5b=~@!sE;utSSYg-$(pU( z5fSCko^;`Fg3sX47FuORqz~MLI5>K5T4#IPL&fm1^N^lO?V5&8}*m zoIP$s1y7FHBKm(YP!%gpVMq>-7`APBRx#v|DELJRC@y`gAia+vCZK`1mA`zs$`sWK z)QO)u9>V?lYjlz?R!&hc@kH1S7i_CAmZItMI`uw(AKmaiS$9k*L9hS5Bw+BpKK%iF ze3x6_TZqB`m5T4h=X=FjD_>OiGpAe_wE&jxmrzbJy$r zsB+r&40dzJndiG?0mDJ1$L+cY*J0xeDhEmgdcN3$%S??pr@@W$;`4y^2R05?ndZi@ zN#RTOD&e;sztalRsmxOvO!$0RHnd9r#0%j!TgH`v!-o!Ha(&bF+X%00DW1R#? z&iuvtZ-P~wmx8Nqb0Po2+!$B3s;licp}1eekFYX$&q_i%0fMdr!?=#-TA3=dwLi}O z&eTW?xQE^#5*qz;{u;-jloC(_DUQ^?Rif#WA5`M4J7+XniRUvO@`#nwaSWW#RKeXd z%syhWng~22K^nNEG+G$?y>-NFse%P{l#$|)^tH((qs>r z!mXjqudl2m;J1nr$>=;(A=9uBwzP(K(8$y_`nr4_ZuSx?O| zi$3p7$FJbA8koKcj#6F^Y5rra#QNsUeQF!;(B)j`yWsu#)gJY7ZnU>`U?MQhQhY($ zY;0BVc}C)6Ai=fRd1B#wB+FGLOT5PiBSH(&IIlq zb%}U+EZ+xd6*ab>t{9`h$$`*kBTvWZIb9F5t6Z5pY4L^dLbjG{DKTlws|uOJvL0Pu zpT(N;w$nwcDx_Mgeevw^$gWf!Q_3Mxb{dR%oYp)9&8|k@MUB)9ih8ekvt>dGH_4s$ zCT~FNCmw|%d4(-ctS*-oZ-wn*><(OU+V}5Tu6N+0r+fTWJ6fZLyG6cfczX4(gVzhI z%-}}3e*!Y1JQ*xqX_{GGP1v&!YD;}-evDXPk5hCO|CBc@vCI0VW}dFB-1Dyup%IO& z4AldGGH6I&&mLLF7e)WhVoEW)oDp6))7}mk!_oClnxG@t^d;i!Er=x(FBkd1C-?^& zK~6$_DM~s=FusLLP=<&3NAXtnjo-klyX#^U?7&z zg1SEt3zq6a8R;=#68h_l*}P4HlQTDCCQyF4Kyunqz+C@$TBRf14%nyD^X|$yGxnLK zwBS(a@gQDbtdFp24WRUjKc1I4LttwItYNc}Y@466H=g4a^}jRAE@2_SN?Sz#)_T4n z<;n6S?F^W$eka-dLtppdC%c=IlSiwJ_fMuE#(0O!m6#xCR~B7x8I@F|vN@`u$;Tij z{+toduqAFN<#gIZo~V-iL;qYCi?S1>`g`?oSi`AK;wBM0F?CSdK}%Nf`_66|0Dnw; zueS%a8m8$%)O?EAE6cDYzcK-Lp~*7p@%?Fk=txI?Wh+Eh6@tWWeO9;Oq}>#^&coMI z!llva8G2vQVNb5G91baR!pd=Xi%@uMPWTzB>!m!0zQ75hyRV4vtH8UF|K(tNRh%{d zFAwKXH@rA27pqS`RO6(d7H&cZfDrRRNyd~dM@PHT?|6RhFFnuGW*L@0y z*t+xybc~r6@lm$2=%sy=^`z-|D%f-pE9Q2 z_fi1Dl}x0SIXoU=3+2kryB(CD#64P&QD(;9_Ohf+o1$z%_S^NJvA}Z=;i+FChKhqo zxl%rt+0-#ga`;b8%j?-upemo4w;Mq&%Afc;pG^B0qf~|k(1<4Y_E1*5cn?n1APhCdlJ%4MusVi6 zyEr!AomuD_!tmli1ln7JujdN?iWb>5H++UtmX698sjfSisy5(G=={C@0wWerAZmr+ zq-D1u|MBV~XE-a$dP&0Cx7#p6_gp8Hca&A83O(0aWtBz?Cd@Q5=p{uDAy4XL3!&lp zl-?Wymh0G&AEbIhOt79p&SQOub~D9MJ{83IpQ!RaTnqzO3I3uJeH`^~>WWQ5*cD7j z-d~d*$JLmDn-{$Knqp>2o?PeCKzzNYbyMxG`Y5^1m&|L3ImeyF5~PS&*h<&1E;W zzjJ@%DaqmXLOF?r+Jgs?u91roUmB4+J)B}jPd~USxWI*?KcwQCuYpq#pXihe>td~HU#cRLU5BL`_J@-V8SieXUb4<8sd|BK>Zs)(($aX%DgD}|X)m`~&NoNXI|>=eb+ zZ*j-_VcY;#bh0%`0*fgm6IW|Dph*8j}ZTGPZ|z`3-8n8ZP+L=(Ne9!`@xEy0I# z2J@A@c)FV@Gn|E+MVzzUINR*~1po+^AL(VKBogZzHfy%NAT=?h@sl?j`Yr=_SVp5nT(9 zio%J~|6^s!ksUfr3a>(oKfg@54FQu5bwVGSl)iOi0k&&FEgpE*rqh+QfeWwr-$elgf`-Z3cV}213_~ik+K;1rPZsBJ@$fxZ`@fwI}Z=iwR_~1lW&&Z`5vw zsNwcLh|Smq+G|&*ZYG=~BppZYcIf1jX%!X)BO6LL@TPpx4Vn#47a|@k5jFA%)^9Wl zuA8AK`XydDnalaP#GN|t_s2S@C>TSH)s(5@Fi6bMW^>S?VpIf;=Sn3 zPd`owMv7!GQtUF)e*=l29fscwsELSo?gT|?1GoyplZGh=Tj>)C|_z1`I@&+ZtKrS#(PK(OH>e$ru2?1WPR-?gTPN^QVjH0d@c*HMYI~{O2B`z z7Ud_OY14U#(D{R*r&K`` z+1OBud40o{ahCCzPdGKGS1%#FCx8F(&e{dEpTK(+DNU3m|6vgP^BW;i>g|+rt8~fwZ;$S2 z&v~)VRiyvN`J1blIJE~58%)A~tl!jQW*aBuvVxm$WCWVJ=k0R0{-LO_F zbAANV(N`>`=r1t(&}b%^P`6zepLJ4g{!@6$U<$43wLX72EyK^ZsPKG;;nM^L26#XO z5`TXParxEFvH{$(JV9A1hd!0Uyhd1dq~M*NbcB#_T2^inN$r@ojczBC+dPoq zzLK+if|ziGmf9DpvGfP=fX>=uzCJ~=@&>9{Uyfq7IOzz@L>y|*F!?`djSSMv5v^ob zIhIe3Pth$r^oG>@c9kSL2&hN5L?)H)CaehWBIf!}eA544t19O8bi2WG`H>Tqvr|14TwytWg&#@l178i(}Y zT6+meOlWHQTQ4SW)1OQ$2u^&i$bQQ~yq|hWWwN_zJ6XZU6;j1R#$CdXIfWZr3|;UR zNQM5>#G@%oKBpa*Ucr>BkCJ|9?|hqDkR#Bv&^=)2KEZX(ST`KNc9 zEWLn4Ua@!y-Olzuyi&Q?_V&|;}QCj8<<7kBJJ;;O0VGPsBe)=If9Wr*0 zCT8I32FIuBV(wZGf=C#m-ZqMgoA*OI{#BUDY_@^t;s&$qH-xpPDgzu$2#}bkYFW7khUaJzJE6dn z_}VwxhC!7k$UH$rz^yjm%K1AYQmlk2c0Hpz(w4G~qLZ6ms{;{viE8RMapWZjPT9Fw4$>5f-jwE1MXhE!7i01-6A`mOQ?6t52pPy_~k+#~VopLdm>_$VVUC zBp8!xeyZ}E?f%TVl1WQReru^}Q8k{ky2E2%%+9;&K2(NAYt?Mj{Enj?@|GW8K5c}Y z@rfx4ZYm=zQSEQgZuX}Ldp=Fos;Vs&A@0B=aE7))~#2@`k^DR*pY)-w_OJ{Midy#u|c%Quq9&uGO+1;2~2#t($nr zB$J#Q0f3pxEEeS&_wwfB)?g@Yo7EkDjuU4A2B$FOf^6w86%x)zLAs4(6XwillKuLEs6;o{CHSZ?m?}T>W{}pgJ#LcTw|7W8t?eB z9zq>H)j%ub<>Qec^DS>j`(rSn#H$1a>{V3A?U%H7LlD;r)LG zqt9S4Dhid)Kt1hNWVp4rVfNlUU+ciZ$y5e1t4qxbu~qerqph5FBPI56tPG!Akb@}l zkJgRw(bT-7Z9H%?FdL=_*sK)r|4DOsjAGSb(o3rMhQrou+oba}61=FmCL9xBL8Hku ztMuT?(!Ss|p(E|0384LMIQ4B!q*s*enUE!f|CZ{;We%?-tBH(!p);3p*Z$AygJKtu zMHkSC3&-`v|LJBKaQN&bioDiINa!;4=WK{awY%#f-6149?TfkARm?r)5P4Xtm|ABx zu25XVHm0JaNU7^vlOT&J=82$rUopF zkshwsQ`<%6o{|5~L)$D~rmXw+^j1VNK++vYVvdxxau>%#;aUh(9~$_>tgOeOh`Okp z_z5yp3I$;{yr!5`1z_BTh{afaV&MIqI`>XYn9`Y%XOSOjmmD8~S)1UFq}?3Vu0O{O z*_M;3+-N3c+yTJ2gutP=Vg(oB76Uc1=g8=5%Cx(ni-Ay`i>X%1*k4pyrKl;LTD>^C z)HYq8T!zRw`U@O12MmwC6IdG|nYChrU;B|FK8>1pE{qPW>HkiYLhue3KNIfk+F-*B zl7R>~AmWDp`3vY$cDCKq+LK3Nil@RtluKDTs{;iPZs{q!O_r{N_1lo8mQ1DcDd`eM@W zccuKL$~Yx5OmkYkk{ICEM8eG|;1k+HT&5-8*eN_z{3==b?{`CRRzfXOlcLT5YhQ)M zau+N}m5@mXH5S(!HmMC#d_gJ_DYh??cEQ~lgJc_^rJq0mq_!~h7TZBFRQS9-aSc7EPJ{4VZc#thpFUEg}7^vE?#1M_) zF&7O^vy_l5_kN7?w+qh@BP*Y;nkRfurofH}%V5R%QnVFI zJN7h@P`(8Y4nEQlaN5lYkf@OkUtsih17ZsJB~&qap{2T6saB)I_2ZSMSnH1L>L~3V zUvAJus*V{m(C}$xsZ6QNS`FmG++3=Ow@#>B`&j)_X-y|%g?6<>g1@hOk%#}Y5e z$^3KPjhs!#QX})_DpluyRNgU@|0e-qpa;p#E;`29g)=hn11JE8&_E^xJGB8K+6&N( zZW266fzLNFhP0!2G9PGynL~R7vo(rYLdsdfK6$GzT9YQ&8MJNC1Z*WSbdWLrHLduw zF2Ut~M2jo@T+sazlF6<76`7(cd6%`8vs*@hrLjPx-x^Z@ zlFo>!FxQv|IGd$K8vnuGRc%?wg_IS)m}nnSSvKy3`zU_SLABt zg4O>_Z%dy7^X>BVAVhB?G|X%WD(A}dBxo1CAU!1e6s5n@Vep?jW;FyS;W7+f4wEp2 zQNuSZgUeK%3+^+-8oSesES1xLj^eQsPuXhdL`=$o45SujvHuVR;r|p>0T=Kd`eBAF zdKoT%L9>QGAe)9rh)PTdE|A#CojS@WSyq4a2OeH?DvcO4HN{XDA&&R`ho@sKm?JT*kbXo zyMKkATtfXd*T}66=()B6E#+EL157Pt(wH`NRAYmAo8oaUZy$V@Ci4aUP+*y(HLibi zWS?4L7kfVCqwvkPCb{yZ2-i;W9u`a1+ki^NVJ60A!7)cuxr^7plXOH97IZNn89oDCx=6pu~QNAS~pKEN8v8kT482TxK zi6C2d8=r~#KkXFyf6k`<=Wq+o3#{_CQ+HX2{)ecv{`;Hb^{^)2$x>ZnuNREuVQRs$ zm4f!4X&)#7fFk zyJ$p3xNz0py3ABDoFr&i(*#)Vf|#ATy_#NFng; zAt*+GLN7vYF7a@}6E7+yMyQ*rRF~~p&?o{-e^R~x3{R1h2(VnRGM+X(N6k_P^x#-y zEQ&Z8ba+Cp+Gd-y58t4}@l`hINHx3zzngWmTKy~8|Igqd{o*uwf0L-{V?_Uy<%P8+ z1-DJ3(jBK0pCex*>^nrlR{ApUWfOS#M?CsBuvkLC<;>=ycry~7qUK)h^wn2`(oh6J zn-&9!%I?`mbIXs-YGK%iVw>KALatSn6J9fRvDq_QZ!f?m7z3QPxnP)0(y(c7Q;j%q z(bYwf!NY4Suz>FdS3F2VLknvequMh2T%cldrv2VevW+0~@~5!W`n1rMQ^m9^U@HV9 zTz|mIr081c|0MHiAY~7z;3o?(MZE3M44ZC#i1-U3-kTi#-1eC}>=#n|rMU>^(Ip0w zb3ZoXV*E0Qkf^N4!`{wv7Y1Bm###bXRBMtVbBbB0G-w8%mA+Mtm?S^(*hJHN#FcH* zg0b40gGmM& zbM+&`EPY~unYg-c(8AndP=$|Wj2C!3$A}-w|8E`w*15>3Wxp~--S0N!fvIF(rtW74 zJ8)2yac9YUr~f8;OWzlTt%YmGoi?Oh&d#z;AQ`7fU8XhE&kgi=V-qDpaoGfI(L_~o zqieu1R3%2hf(JS#i;1kkp=SQmZ#XeewObO!Qcz#iutOhlca6A;$zpbw7Uah3p);ZM z^FNu5rEF2KEHM8wVc!0XixW}dL>lz4TPaxkAfcu%YeG`yrcqd{KvtA3c$j0^7lF|S xk-uG+@OP6@bBuS(M1jM{kQ?4e0dm~c2Sl*k4X&2qn#L#aM@B*s%v_8D|1XDgir@eM From c29b619e15d3cad494e37c0a60f1b4be47abc777 Mon Sep 17 00:00:00 2001 From: DONNOT Benjamin Date: Fri, 13 Dec 2024 12:16:36 +0100 Subject: [PATCH 39/39] fix some last issue before merging on dev_turnoff_gen_load Signed-off-by: DONNOT Benjamin --- grid2op/Backend/backend.py | 2 ++ grid2op/Backend/pandaPowerBackend.py | 8 +------- grid2op/tests/aaa_test_backend_interface.py | 14 ++++++-------- 3 files changed, 9 insertions(+), 15 deletions(-) diff --git a/grid2op/Backend/backend.py b/grid2op/Backend/backend.py index 2dc61d7b..62c8cc47 100644 --- a/grid2op/Backend/backend.py +++ b/grid2op/Backend/backend.py @@ -1226,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: """ diff --git a/grid2op/Backend/pandaPowerBackend.py b/grid2op/Backend/pandaPowerBackend.py index 9fabd2d7..b073c16c 100644 --- a/grid2op/Backend/pandaPowerBackend.py +++ b/grid2op/Backend/pandaPowerBackend.py @@ -1116,13 +1116,7 @@ def runpf(self, is_dc : bool=False) -> Tuple[bool, Union[Exception, None]]: self.load_theta[:], ) = self._loads_info() - load_in_service = self._grid.load["in_service"] - if not is_dc: - if not np.isfinite(self.load_v[load_in_service]).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 diff --git a/grid2op/tests/aaa_test_backend_interface.py b/grid2op/tests/aaa_test_backend_interface.py index 71cabe00..29ccd59a 100644 --- a/grid2op/tests/aaa_test_backend_interface.py +++ b/grid2op/tests/aaa_test_backend_interface.py @@ -1001,7 +1001,9 @@ def test_19_isolated_storage_stops_computation(self, allow_detachment=DEFAULT_AL 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. + 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 : @@ -1010,8 +1012,6 @@ def test_20_disconnected_load_stops_computation(self, allow_detachment=DEFAULT_A - 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. @@ -1041,6 +1041,8 @@ def test_20_disconnected_load_stops_computation(self, allow_detachment=DEFAULT_A 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 : @@ -1049,8 +1051,6 @@ def test_21_disconnected_gen_stops_computation(self, allow_detachment=DEFAULT_AL - 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. @@ -1091,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. @@ -1721,7 +1719,7 @@ def test_31_disconnected_storage_with_p_stops_computation(self, allow_detachment Currently this stops the computation of the environment and lead to a game over. .. note:: - This test is also used in `attr:AAATestBackendAPI.test_31_allow_detachment` + This test is also used in `attr:AAATestBackendAPI.test_33_allow_detachment` """ self.skip_if_needed()