diff --git a/grid2op/Backend/backend.py b/grid2op/Backend/backend.py index 24d789f6..f736959d 100644 --- a/grid2op/Backend/backend.py +++ b/grid2op/Backend/backend.py @@ -186,6 +186,7 @@ def __init__(self, self._missing_detachment_support_info : bool = True self.detachment_is_allowed : bool = DEFAULT_ALLOW_DETACHMENT + def can_handle_more_than_2_busbar(self): """ .. versionadded:: 1.10.0 diff --git a/grid2op/Backend/protectionScheme.py b/grid2op/Backend/protectionScheme.py new file mode 100644 index 00000000..863a3440 --- /dev/null +++ b/grid2op/Backend/protectionScheme.py @@ -0,0 +1,136 @@ +import copy +import logging +from typing import Tuple, Union, Any, List, Optional +import numpy as np + +from grid2op.dtypes import dt_int +from grid2op.Backend.backend import Backend +from grid2op.Parameters import Parameters +from grid2op.Exceptions import Grid2OpException +from grid2op.Backend.thermalLimits import ThermalLimits + +class DefaultProtection: + """ + Advanced class to manage network protections and disconnections. + """ + + def __init__( + self, + backend: Backend, + parameters: Optional[Parameters] = None, + thermal_limits: Optional[ThermalLimits] = None, + is_dc: bool = False, + logger: Optional[logging.Logger] = None, + ): + """ + Initializes the network state with customizable protections. + """ + self.backend = backend + self._parameters = copy.deepcopy(parameters) if parameters else Parameters() + self._validate_input(self.backend, self._parameters) + + self.is_dc = is_dc + self.thermal_limits = thermal_limits + + self._thermal_limit_a = self.thermal_limits.limits if self.thermal_limits else None + self.backend.thermal_limit_a = self._thermal_limit_a + + self._hard_overflow_threshold = self._get_value_from_parameters("HARD_OVERFLOW_THRESHOLD") + self._soft_overflow_threshold = self._get_value_from_parameters("SOFT_OVERFLOW_THRESHOLD") + self._nb_timestep_overflow_allowed = self._get_value_from_parameters("NB_TIMESTEP_OVERFLOW_ALLOWED") + self._no_overflow_disconnection = self._get_value_from_parameters("NO_OVERFLOW_DISCONNECTION") + + self.disconnected_during_cf = np.full(self.thermal_limits.n_line, fill_value=-1, dtype=dt_int) + self._timestep_overflow = np.zeros(self.thermal_limits.n_line, dtype=dt_int) + self.conv_ = self._run_power_flow() + self.infos: List[str] = [] + + if logger is None: + self.logger = logging.getLogger(__name__) + self.logger.disabled = True + else: + self.logger: logging.Logger = logger.getChild("grid2op_BaseEnv") + + def _validate_input(self, backend: Backend, parameters: Optional[Parameters]) -> None: + if not isinstance(backend, Backend): + raise Grid2OpException(f"Argument 'backend' must be of type 'Backend', received: {type(backend)}") + if parameters and not isinstance(parameters, Parameters): + raise Grid2OpException(f"Argument 'parameters' must be of type 'Parameters', received: {type(parameters)}") + + def _get_value_from_parameters(self, parameter_name: str) -> Any: + return getattr(self._parameters, parameter_name, None) + + def _run_power_flow(self) -> Optional[Exception]: + try: + return self.backend._runpf_with_diverging_exception(self.is_dc) + except Exception as e: + if self.logger is not None: + self.logger.error( + f"Power flow error: {e}" + ) + return e + + def _update_overflows(self, lines_flows: np.ndarray) -> np.ndarray: + if self._thermal_limit_a is None: + if self.logger is not None: + self.logger.error( + "Thermal limits must be provided for overflow calculations." + ) + raise ValueError("Thermal limits must be provided for overflow calculations.") + + lines_status = self.backend.get_line_status() # self._thermal_limit_a remains fixed. self._soft_overflow_threshold = 1 + is_overflowing = (lines_flows >= self._thermal_limit_a * self._soft_overflow_threshold) & lines_status + self._timestep_overflow[is_overflowing] += 1 + # self._hard_overflow_threshold = 1.5 + exceeds_hard_limit = (lines_flows > self._thermal_limit_a * self._hard_overflow_threshold) & lines_status + exceeds_allowed_time = self._timestep_overflow > self._nb_timestep_overflow_allowed + + lines_to_disconnect = exceeds_hard_limit | (exceeds_allowed_time & lines_status) + return lines_to_disconnect + + def _disconnect_lines(self, lines_to_disconnect: np.ndarray, timestep: int) -> None: + for line_idx in np.where(lines_to_disconnect)[0]: + self.backend._disconnect_line(line_idx) + self.disconnected_during_cf[line_idx] = timestep + if self.logger is not None: + self.logger.warning(f"Line {line_idx} disconnected at timestep {timestep}.") + + def next_grid_state(self) -> Tuple[np.ndarray, List[Any], Union[None, Exception]]: + try: + timestep = 0 + while True: + power_flow_result = self._run_power_flow() + if power_flow_result: + return self.disconnected_during_cf, self.infos, power_flow_result + + lines_flows = self.backend.get_line_flow() + lines_to_disconnect = self._update_overflows(lines_flows) + + if not lines_to_disconnect.any(): + break + + self._disconnect_lines(lines_to_disconnect, timestep) + timestep += 1 + + return self.disconnected_during_cf, self.infos, None + + except Exception as e: + if self.logger is not None: + self.logger.exception("Unexpected error in calculating the network state.") + return self.disconnected_during_cf, self.infos, e + +class NoProtection(DefaultProtection): + """ + Class that disables overflow protections while keeping the structure of DefaultProtection. + """ + def __init__(self, backend: Backend, thermal_limits: ThermalLimits, is_dc: bool = False): + super().__init__(backend, parameters=None, thermal_limits=thermal_limits, is_dc=is_dc) + + def next_grid_state(self) -> Tuple[np.ndarray, List[Any], None]: + """ + Ignores protections and returns the network state without disconnections. + """ + return self.disconnected_during_cf, self.infos, self.conv_ + +class BlablaProtection: + pass \ No newline at end of file diff --git a/grid2op/Backend/thermalLimits.py b/grid2op/Backend/thermalLimits.py new file mode 100644 index 00000000..25b991e9 --- /dev/null +++ b/grid2op/Backend/thermalLimits.py @@ -0,0 +1,220 @@ +from typing import Union, List, Optional, Dict +try: + from typing import Self +except ImportError: + from typing_extensions import Self + +import logging +import numpy as np +import copy + +import grid2op +from grid2op.dtypes import dt_float +from grid2op.Exceptions import Grid2OpException + +class ThermalLimits: + """ + Class for managing the thermal limits of power grid lines. + """ + + def __init__( + self, + _thermal_limit_a: Optional[np.ndarray] = None, + line_names: Optional[List[str]] = None, + n_line: Optional[int] = None, + logger: Optional[logging.Logger] = None, + ): + """ + Initializes the thermal limits manager. + + :param thermal_limits: Optional[np.ndarray] + Array of thermal limits for each power line. Must have the same length as the number of lines. + :param line_names: Optional[List[str]] + List of power line names. + :param n_line: Optional[int] + Number of lines (can be passed explicitly or inferred from `thermal_limits` or `line_names`). + + :raises ValueError: + If neither `thermal_limits` nor `n_line` and `line_names` are provided. + """ + if _thermal_limit_a is None and (n_line is None and line_names is None): + raise ValueError("Must provide thermal_limits or both n_line and line_names.") + + self._thermal_limit_a = _thermal_limit_a + self._n_line = n_line + self._name_line = line_names + + if logger is None: + self.logger = logging.getLogger(__name__) + self.logger.disabled = True + else: + self.logger: logging.Logger = logger.getChild("grid2op_BaseEnv") + + @property + def n_line(self) -> int: + return self._n_line + + @n_line.setter + def n_line(self, new_n_line: int) -> None: + if new_n_line <= 0: + raise ValueError("Number of lines must be a positive integer.") + self._n_line = new_n_line + if self.logger is not None: + self.logger.info(f"Number of lines updated to {self._n_line}.") + + @property + def name_line(self) -> Union[List[str], np.ndarray]: + return self._name_line + + @name_line.setter + def name_line(self, new_name_line: Union[List[str], np.ndarray]) -> None: + if isinstance(new_name_line, np.ndarray): + if not np.all([isinstance(name, str) for name in new_name_line]): + raise ValueError("All elements in name_line must be strings.") + elif isinstance(new_name_line, list): + if not all(isinstance(name, str) for name in new_name_line): + raise ValueError("All elements in name_line must be strings.") + else: + raise ValueError("Line names must be a list or numpy array of non-empty strings.") + + if self._n_line is not None and len(new_name_line) != self._n_line: + raise ValueError("Length of name list must match the number of lines.") + + self._name_line = new_name_line + if self.logger is not None: + self.logger.info("Power line names updated") + + @property + def limits(self) -> np.ndarray: + """ + Gets the current thermal limits of the power lines. + + :return: np.ndarray + The array containing thermal limits for each power line. + """ + return self._thermal_limit_a + + @limits.setter + def limits(self, new_limits: Union[np.ndarray, Dict[str, float]]): + """ + Sets new thermal limits. + + :param new_limits: Union[np.ndarray, Dict[str, float]] + Either a numpy array or a dictionary mapping line names to new thermal limits. + + :raises ValueError: + If the new limits array size does not match the number of lines. + :raises Grid2OpException: + If invalid power line names are provided in the dictionary. + If the new thermal limit values are invalid (non-positive or non-convertible). + :raises TypeError: + If the input type is not an array or dictionary. + """ + if isinstance(new_limits, np.ndarray): + if new_limits.shape[0] == self.n_line: + self._thermal_limit_a = 1.0 * new_limits.astype(dt_float) + elif isinstance(new_limits, dict): + for el in new_limits.keys(): + if not el in self.name_line: + raise Grid2OpException( + 'You asked to modify the thermal limit of powerline named "{}" that is not ' + "on the grid. Names of powerlines are {}".format( + el, self.name_line + ) + ) + for i, el in enumerate(self.name_line): + if el in new_limits: + try: + tmp = dt_float(new_limits[el]) + except Exception as exc_: + raise Grid2OpException( + 'Impossible to convert data ({}) for powerline named "{}" into float ' + "values".format(new_limits[el], el) + ) from exc_ + if tmp <= 0: + raise Grid2OpException( + 'New thermal limit for powerlines "{}" is not positive ({})' + "".format(el, tmp) + ) + self._thermal_limit_a[i] = tmp + + def env_limits(self, thermal_limit): + """ + """ + if isinstance(thermal_limit, dict): + tmp = np.full(self.n_line, fill_value=np.NaN, dtype=dt_float) + for key, val in thermal_limit.items(): + if key not in self.name_line: + raise Grid2OpException( + f"When setting a thermal limit with a dictionary, the keys should be line " + f"names. We found: {key} which is not a line name. The names of the " + f"powerlines are {self.name_line}" + ) + ind_line = (self.name_line == key).nonzero()[0][0] + if np.isfinite(tmp[ind_line]): + raise Grid2OpException( + f"Humm, there is a really strange bug, some lines are set twice." + ) + try: + val_fl = float(val) + except Exception as exc_: + raise Grid2OpException( + f"When setting thermal limit with a dictionary, the keys should be " + f"the values of the thermal limit (in amps) you provided something that " + f'cannot be converted to a float. Error was "{exc_}".' + ) + tmp[ind_line] = val_fl + + elif isinstance(thermal_limit, (np.ndarray, list)): + try: + tmp = np.array(thermal_limit).flatten().astype(dt_float) + except Exception as exc_: + raise Grid2OpException( + f"Impossible to convert the vector as input into a 1d numpy float array. " + f"Error was: \n {exc_}" + ) + if tmp.shape[0] != self.n_line: + raise Grid2OpException( + "Attempt to set thermal limit on {} powerlines while there are {}" + "on the grid".format(tmp.shape[0], self.n_line) + ) + if (~np.isfinite(tmp)).any(): + raise Grid2OpException( + "Impossible to use non finite value for thermal limits." + ) + else: + raise Grid2OpException( + f"You can only set the thermal limits of the environment with a dictionary (in that " + f"case the keys are the line names, and the values the thermal limits) or with " + f"a numpy array that has as many components of the number of powerlines on " + f'the grid. You provided something with type "{type(thermal_limit)}" which ' + f"is not supported." + ) + + self._thermal_limit_a = tmp + if self.logger is not None: + self.logger.info("Env thermal limits successfully set.") + + def update_limits_from_vector(self, thermal_limit_a: np.ndarray) -> None: + """ + Updates the thermal limits using a numpy array. + + :param thermal_limit_a: np.ndarray + The new array of thermal limits (in Amperes). + """ + thermal_limit_a = np.array(thermal_limit_a).astype(dt_float) + self._thermal_limit_a = thermal_limit_a + if self.logger is not None: + self.logger.info("Thermal limits updated from vector.") + + def update_limits(self, env : "grid2op.Environment.BaseEnv") -> None: + pass + + def copy(self) -> Self: + """ + Creates a deep copy of the current ThermalLimits instance. + + :return: ThermalLimits + A new instance with the same attributes. + """ + return copy.deepcopy(self) \ No newline at end of file diff --git a/grid2op/Environment/_obsEnv.py b/grid2op/Environment/_obsEnv.py index c8b8ac79..760b8eb8 100644 --- a/grid2op/Environment/_obsEnv.py +++ b/grid2op/Environment/_obsEnv.py @@ -193,9 +193,9 @@ def _init_backend( self._game_rules.initialize(self) self._legalActClass = legalActClass - # self._action_space = self._do_nothing - self.backend.set_thermal_limit(self._thermal_limit_a) - + # # self._action_space = self._do_nothing + self.ts_manager.limits = self._thermal_limit_a # old code line : self.backend.set_thermal_limit(self._thermal_limit_a) + from grid2op.Observation import ObservationSpace from grid2op.Reward import FlatReward ob_sp_cls = ObservationSpace.init_grid(type(backend), _local_dir_cls=self._local_dir_cls) @@ -207,7 +207,6 @@ def _init_backend( _local_dir_cls=self._local_dir_cls ) self._observationClass = self._observation_space.subtype # not used anyway - # create the opponent self._create_opponent() @@ -555,4 +554,4 @@ def close(self): ]: if hasattr(self, attr_nm): delattr(self, attr_nm) - setattr(self, attr_nm, None) + setattr(self, attr_nm, None) \ No newline at end of file diff --git a/grid2op/Environment/baseEnv.py b/grid2op/Environment/baseEnv.py index e3c32323..7d440b70 100644 --- a/grid2op/Environment/baseEnv.py +++ b/grid2op/Environment/baseEnv.py @@ -26,7 +26,7 @@ from grid2op.Observation import (BaseObservation, ObservationSpace, HighResSimCounter) -from grid2op.Backend import Backend +from grid2op.Backend import Backend, thermalLimits, protectionScheme from grid2op.dtypes import dt_int, dt_float, dt_bool from grid2op.Space import (GridObjects, RandomObject, @@ -569,6 +569,10 @@ def __init__( self.__is_init = False self.debug_dispatch = False + # Thermal limit and protection + self._init_thermal_limit() + self.protection : protectionScheme = None + # to change the parameters self.__new_param = None self.__new_forecast_param = None @@ -667,6 +671,48 @@ def __init__( # slack (1.11.0) self._delta_gen_p = None + + def _init_thermal_limit(self): + self.ts_manager = thermalLimits.ThermalLimits( + _thermal_limit_a = self._thermal_limit_a, + n_line=self.n_line, + line_names=self.name_line + ) + + def _init_protection(self): + """ + Initialise le système de protection du réseau avec gestion des erreurs et logs. + """ + try: + self.logger.info("Initialisation du système de protection...") + + initializerProtection = protectionScheme.DefaultProtection( + backend=self.backend, + parameters=self.parameters, + thermal_limits=self.ts_manager, + is_dc=self._env_dc, + logger=self.logger + ) + + if self._no_overflow_disconnection or initializerProtection.conv_ is not None: + self.logger.warning("Utilisation de NoProtection car _no_overflow_disconnection est activé " + "ou la convergence du power flow a échoué.") + self.protection = protectionScheme.NoProtection( + backend=self.backend, + thermal_limits=self.ts_manager, + is_dc=self._env_dc + ) + else: + self.logger.info("Utilisation de DefaultProtection avec succès.") + self.protection = initializerProtection + + except Grid2OpException as e: + self.logger.error(f"Erreur spécifique à Grid2Op lors de l'initialisation de la protection : {e}") + raise + except Exception as e: + self.logger.exception("Erreur inattendue lors de l'initialisation de la protection.") + raise + @property def highres_sim_counter(self): return self._highres_sim_counter @@ -740,8 +786,9 @@ def _custom_deepcopy_for_copy(self, new_obj, dict_=None): # specific to Basic Env, do not change new_obj.backend = self.backend.copy() + new_obj.ts_manager = self.ts_manager.copy() if self._thermal_limit_a is not None: - new_obj.backend.set_thermal_limit(self._thermal_limit_a) + new_obj.ts_manager.limits = self._thermal_limit_a new_obj._thermal_limit_a = copy.deepcopy(self._thermal_limit_a) new_obj.__is_init = self.__is_init @@ -1889,58 +1936,10 @@ def set_thermal_limit(self, thermal_limit): "Impossible to set the thermal limit to a non initialized Environment. " "Have you called `env.reset()` after last game over ?" ) - if isinstance(thermal_limit, dict): - tmp = np.full(self.n_line, fill_value=np.NaN, dtype=dt_float) - for key, val in thermal_limit.items(): - if key not in self.name_line: - raise Grid2OpException( - f"When setting a thermal limit with a dictionary, the keys should be line " - f"names. We found: {key} which is not a line name. The names of the " - f"powerlines are {self.name_line}" - ) - ind_line = (self.name_line == key).nonzero()[0][0] - if np.isfinite(tmp[ind_line]): - raise Grid2OpException( - f"Humm, there is a really strange bug, some lines are set twice." - ) - try: - val_fl = float(val) - except Exception as exc_: - raise Grid2OpException( - f"When setting thermal limit with a dictionary, the keys should be " - f"the names of the lines and the values the thermal limit (in amps) " - f"you provided something that " - f'cannot be converted to a float {type(val)}' - ) from exc_ - tmp[ind_line] = val_fl - - elif isinstance(thermal_limit, (np.ndarray, list)): - try: - tmp = np.array(thermal_limit).flatten().astype(dt_float) - except Exception as exc_: - raise Grid2OpException( - f"Impossible to convert the vector as input into a 1d numpy float array. " - ) from exc_ - if tmp.shape[0] != self.n_line: - raise Grid2OpException( - "Attempt to set thermal limit on {} powerlines while there are {}" - "on the grid".format(tmp.shape[0], self.n_line) - ) - if (~np.isfinite(tmp)).any(): - raise Grid2OpException( - "Impossible to use non finite value for thermal limits." - ) - else: - raise Grid2OpException( - f"You can only set the thermal limits of the environment with a dictionary (in that " - f"case the keys are the line names, and the values the thermal limits) or with " - f"a numpy array that has as many components of the number of powerlines on " - f'the grid. You provided something with type "{type(thermal_limit)}" which ' - f"is not supported." - ) - self._thermal_limit_a[:] = tmp - self.backend.set_thermal_limit(self._thermal_limit_a) - self.observation_space.set_thermal_limit(self._thermal_limit_a) + # update n_line and name_line of ts_manager (old : self.ts_manager.n_line = -1 and self.ts_manager.name_line = None) + self.ts_manager.env_limits(thermal_limit=thermal_limit) + self.backend.thermal_limit_a = self.ts_manager.limits + self.observation_space.set_thermal_limit(self.ts_manager.limits) def _reset_redispatching(self): # redispatching @@ -3172,7 +3171,8 @@ def _update_alert_properties(self, action, lines_attacked, subs_attacked): def _aux_register_env_converged(self, disc_lines, action, init_line_status, new_p) -> Optional[Grid2OpException]: beg_res = time.perf_counter() # update the thermal limit, for DLR for example - self.backend.update_thermal_limit(self) + self.ts_manager.update_limits(self) # old code : self.backend.update_thermal_limit(self) + overflow_lines = self.backend.get_line_overflow() # save the current topology as "last" topology (for connected powerlines) # and update the state of the disconnected powerline due to cascading failure @@ -3268,9 +3268,11 @@ def _aux_register_env_converged(self, disc_lines, action, init_line_status, new_ self._time_extract_obs += time.perf_counter() - beg_res return None - def _backend_next_grid_state(self): + def _protection_next_grid_state(self): """overlaoded in MaskedEnv""" - return self.backend.next_grid_state(env=self, is_dc=self._env_dc) + self._init_thermal_limit() + self._init_protection() + return self.protection.next_grid_state() def _aux_run_pf_after_state_properly_set( self, action, init_line_status, new_p, except_ @@ -3300,7 +3302,7 @@ def _aux_run_pf_after_state_properly_set( try: # compute the next _grid state beg_pf = time.perf_counter() - disc_lines, detailed_info, conv_ = self._backend_next_grid_state() + disc_lines, detailed_info, conv_ = self._protection_next_grid_state() self._disc_lines[:] = disc_lines self._time_powerflow += time.perf_counter() - beg_pf if conv_ is None: @@ -4529,7 +4531,7 @@ def _reset_to_orig_state(self, obs): update the value of the "time dependant" attributes, used mainly for the "_ObsEnv" (simulate) or the "Forecasted env" (obs.get_forecast_env()) """ - self.backend.set_thermal_limit(obs._thermal_limit) + self.ts_manager.limits = obs._thermal_limit if "opp_space_state" in obs._env_internal_params: self._oppSpace._set_state(obs._env_internal_params["opp_space_state"], obs._env_internal_params["opp_state"]) diff --git a/grid2op/Environment/environment.py b/grid2op/Environment/environment.py index 6f13d926..b0b2a87a 100644 --- a/grid2op/Environment/environment.py +++ b/grid2op/Environment/environment.py @@ -26,7 +26,7 @@ from grid2op.Observation import CompleteObservation, ObservationSpace, BaseObservation from grid2op.Reward import FlatReward, RewardHelper, BaseReward from grid2op.Rules import RulesChecker, AlwaysLegal, BaseRules -from grid2op.Backend import Backend +from grid2op.Backend import Backend, thermalLimits from grid2op.Chronics import ChronicsHandler from grid2op.VoltageControler import ControlVoltageFromFile, BaseVoltageController from grid2op.Environment.baseEnv import BaseEnv @@ -85,6 +85,7 @@ def __init__( chronics_handler, backend, parameters, + ts_manager: thermalLimits.ThermalLimits = None, name="unknown", n_busbar:N_BUSBAR_PER_SUB_TYPING=DEFAULT_N_BUSBAR_PER_SUB, allow_detachment:bool=DEFAULT_ALLOW_DETACHMENT, @@ -199,14 +200,15 @@ def __init__( self.spec = None self._compat_glop_version = _compat_glop_version - # needs to be done before "_init_backend" otherwise observationClass is not defined in the # observation space (real_env_kwargs) self._observationClass_orig = observationClass + # for plotting self._init_backend( chronics_handler, backend, + ts_manager, names_chronics_to_backend, actionClass, observationClass, @@ -218,6 +220,7 @@ def _init_backend( self, chronics_handler, backend, + ts_manager, names_chronics_to_backend, actionClass, observationClass, @@ -247,7 +250,13 @@ def _init_backend( type(rewardClass) ) ) - + #thermalLimits: + ts_manager = ts_manager or self.ts_manager + if not isinstance(ts_manager, thermalLimits.ThermalLimits): + raise Grid2OpException( + 'Parameter "ts_manager" used to build the Environment should derived form the ' + 'grid2op.Backend.thermalLimits class, type provided is "{}"'.format(type(ts_manager)) + ) # backend if not isinstance(backend, Backend): raise Grid2OpException( @@ -324,11 +333,13 @@ def _init_backend( self._line_status = np.ones(shape=self.n_line, dtype=dt_bool) self._disc_lines = np.zeros(shape=self.n_line, dtype=dt_int) - 1 + self.ts_manager.n_line = self.n_line + self.ts_manager.name_line = self.name_line if self._thermal_limit_a is None: - self._thermal_limit_a = self.backend.thermal_limit_a.astype(dt_float) + self.ts_manager.limits = self.backend.thermal_limit_a.astype(dt_float) + self._thermal_limit_a = self.ts_manager.limits else: - self.backend.set_thermal_limit(self._thermal_limit_a.astype(dt_float)) - + self.ts_manager.limits = self._thermal_limit_a.astype(dt_float) *_, tmp = self.backend.generators_info() # rules of the game @@ -418,6 +429,7 @@ def _init_backend( # this needs to be done after the chronics handler: rewards might need information # about the chronics to work properly. self._helper_observation_class = ObservationSpace.init_grid(gridobj=bk_type, _local_dir_cls=self._local_dir_cls) + # FYI: this try to copy the backend if it fails it will modify the backend # and the environment to force the deactivation of the # forecasts @@ -442,7 +454,6 @@ def _init_backend( self._reward_helper.initialize(self) for k, v in self.other_rewards.items(): v.initialize(self) - # controller for voltage if not issubclass(self._voltagecontrolerClass, BaseVoltageController): raise Grid2OpException( @@ -951,7 +962,7 @@ def reset_grid(self, # self.backend.assert_grid_correct() if self._thermal_limit_a is not None: - self.backend.set_thermal_limit(self._thermal_limit_a.astype(dt_float)) + self.ts_manager.limits = self._thermal_limit_a.astype(dt_float) self._backend_action = self._backend_action_class() self.nb_time_step = -1 # to have init obs at step 1 (and to prevent 'setting to proper state' "action" to be illegal) @@ -1581,6 +1592,7 @@ def get_kwargs(self, res["epsilon_poly"] = self._epsilon_poly res["tol_poly"] = self._tol_poly res["thermal_limit_a"] = self._thermal_limit_a + res["ts_manager"] = self.ts_manager res["voltagecontrolerClass"] = self._voltagecontrolerClass res["other_rewards"] = {k: v.rewardClass for k, v in self.other_rewards.items()} res["name"] = self.name @@ -2203,6 +2215,7 @@ def get_params_for_runner(self): res["max_iter"] = self.chronics_handler.max_iter res["gridStateclass_kwargs"] = dict_ res["thermal_limit_a"] = self._thermal_limit_a + res["ts_manager"] = self.ts_manager res["voltageControlerClass"] = self._voltagecontrolerClass res["other_rewards"] = {k: v.rewardClass for k, v in self.other_rewards.items()} res["grid_layout"] = self.grid_layout diff --git a/grid2op/Episode/EpisodeReboot.py b/grid2op/Episode/EpisodeReboot.py index bda5218d..3eda0b32 100644 --- a/grid2op/Episode/EpisodeReboot.py +++ b/grid2op/Episode/EpisodeReboot.py @@ -182,6 +182,8 @@ def load(self, backend, agent_path=None, name=None, data=None, env_kwargs={}): del env_kwargs["opponent_class"] if "name" in env_kwargs: del env_kwargs["name"] + if "ts_manager" in env_kwargs: + del env_kwargs["ts_manager"] seed = None with open(os.path.join(agent_path, name, "episode_meta.json")) as f: @@ -244,9 +246,7 @@ def _assign_state(self, obs): # # TODO check that the "stored" "last bus for when the powerline were connected" are # # kept there (I might need to do a for loop) self.env.backend.update_from_obs(obs) - disc_lines, detailed_info, conv_ = self.env.backend.next_grid_state( - env=self.env - ) + disc_lines, detailed_info, conv_ = self.env.protection.next_grid_state() if conv_ is None: self.env._backend_action.update_state(disc_lines) self.env._backend_action.reset() diff --git a/grid2op/MakeEnv/MakeFromPath.py b/grid2op/MakeEnv/MakeFromPath.py index 6692edfa..9a3702fc 100644 --- a/grid2op/MakeEnv/MakeFromPath.py +++ b/grid2op/MakeEnv/MakeFromPath.py @@ -1070,7 +1070,7 @@ def make_from_dataset_path( # Update the thermal limit if any if thermal_limits is not None: env.set_thermal_limit(thermal_limits) - + # Set graph layout if not None and not an empty dict if graph_layout is not None and graph_layout: try: diff --git a/grid2op/Observation/baseObservation.py b/grid2op/Observation/baseObservation.py index df727e19..9cc7a48f 100644 --- a/grid2op/Observation/baseObservation.py +++ b/grid2op/Observation/baseObservation.py @@ -4405,7 +4405,7 @@ def _update_obs_complete(self, env: "grid2op.Environment.BaseEnv", with_forecast self.target_dispatch[:] = env._target_dispatch self.actual_dispatch[:] = env._actual_dispatch - self._thermal_limit[:] = env.get_thermal_limit() + self._thermal_limit[:] = env.ts_manager.limits if self.redispatching_unit_commitment_availble: self.gen_p_before_curtail[:] = env._gen_before_curtailment diff --git a/grid2op/Observation/observationSpace.py b/grid2op/Observation/observationSpace.py index 1bc0290a..13f698ee 100644 --- a/grid2op/Observation/observationSpace.py +++ b/grid2op/Observation/observationSpace.py @@ -10,6 +10,8 @@ import copy import logging import os +from grid2op.Backend import thermalLimits + from grid2op.Exceptions.envExceptions import EnvError from grid2op.Observation.serializableObservationSpace import ( @@ -133,7 +135,10 @@ def __init__( self._real_env_kwargs = {} self._observation_bk_class = observation_bk_class self._observation_bk_kwargs = observation_bk_kwargs - + + # Initialisation de la class thermal limits + self.ts_manager : thermalLimits.ThermalLimits = copy.deepcopy(env.ts_manager) + def set_real_env_kwargs(self, env): if not self.with_forecast: return @@ -184,7 +189,7 @@ def _create_obs_env(self, env, observationClass): parameters=self._simulate_parameters, reward_helper=self.reward_helper, action_helper=self.action_helper_env, - thermal_limit_a=env.get_thermal_limit(), + thermal_limit_a=env.ts_manager.limits, legalActClass=copy.deepcopy(env._legalActClass), other_rewards=other_rewards, helper_action_class=env._helper_action_class, @@ -217,8 +222,8 @@ def _aux_create_backend(self, env, observation_bk_class, observation_bk_kwargs, self._backend_obs.assert_grid_correct() self._backend_obs.runpf() self._backend_obs.assert_grid_correct_after_powerflow() - self._backend_obs.set_thermal_limit(env.get_thermal_limit()) - + self.ts_manager.limits = env.get_thermal_limit() + def _create_backend_obs(self, env, observation_bk_class, observation_bk_kwargs, _local_dir_cls): _with_obs_env = True path_sim_bk = os.path.join(env.get_path_env(), "grid_forecast.json") @@ -396,9 +401,9 @@ def change_reward(self, reward_func): def set_thermal_limit(self, thermal_limit_a): if self.obs_env is not None: - self.obs_env.set_thermal_limit(thermal_limit_a) + self.obs_env.ts_manager.env_limits(thermal_limit_a) if self._backend_obs is not None: - self._backend_obs.set_thermal_limit(thermal_limit_a) + self._backend_obs.thermal_limit_a = thermal_limit_a def reset_space(self): if self.with_forecast: diff --git a/grid2op/Runner/runner.py b/grid2op/Runner/runner.py index 9ee0e28c..370fb423 100644 --- a/grid2op/Runner/runner.py +++ b/grid2op/Runner/runner.py @@ -22,7 +22,7 @@ from grid2op.Rules import AlwaysLegal from grid2op.Environment import Environment from grid2op.Chronics import ChronicsHandler, GridStateFromFile, GridValue, MultifolderWithCache -from grid2op.Backend import Backend, PandaPowerBackend +from grid2op.Backend import Backend, PandaPowerBackend, thermalLimits from grid2op.Parameters import Parameters from grid2op.Agent import DoNothingAgent, BaseAgent from grid2op.VoltageControler import ControlVoltageFromFile @@ -375,6 +375,7 @@ def __init__( gridStateclass_kwargs={}, voltageControlerClass=ControlVoltageFromFile, thermal_limit_a=None, + ts_manager=thermalLimits.ThermalLimits, max_iter=-1, other_rewards={}, opponent_space_type=OpponentSpace, @@ -667,6 +668,7 @@ def __init__( self.verbose = verbose self.thermal_limit_a = thermal_limit_a + self.ts_manager = ts_manager # controler for voltage if not issubclass(voltageControlerClass, ControlVoltageFromFile): @@ -1278,6 +1280,7 @@ def _get_params(self): "gridStateclass_kwargs": copy.deepcopy(self.gridStateclass_kwargs), "voltageControlerClass": self.voltageControlerClass, "thermal_limit_a": self.thermal_limit_a, + "ts_manager": self.ts_manager, "max_iter": self.max_iter, "other_rewards": copy.deepcopy(self._other_rewards), "opponent_space_type": self._opponent_space_type, diff --git a/grid2op/tests/BaseBackendTest.py b/grid2op/tests/BaseBackendTest.py index 2feb91c8..9b02c700 100644 --- a/grid2op/tests/BaseBackendTest.py +++ b/grid2op/tests/BaseBackendTest.py @@ -3218,4 +3218,4 @@ def test_chgtbus_prevDisc(self): self._line_disconnected(LINE_ID, obs) # right way to count it - self._only_sub_impacted(LINE_ID, action, statuses) + self._only_sub_impacted(LINE_ID, action, statuses) \ No newline at end of file diff --git a/grid2op/tests/test_protectionScheme.py b/grid2op/tests/test_protectionScheme.py new file mode 100644 index 00000000..8cdb53cc --- /dev/null +++ b/grid2op/tests/test_protectionScheme.py @@ -0,0 +1,172 @@ +import numpy as np + +import unittest +from unittest.mock import MagicMock + +from grid2op.Backend.backend import Backend +from grid2op.Parameters import Parameters +from grid2op.Exceptions import Grid2OpException + +from grid2op.Backend.thermalLimits import ThermalLimits +from grid2op.Backend.protectionScheme import DefaultProtection, NoProtection + +class TestProtection(unittest.TestCase): + + def setUp(self): + """Initialization of mocks and test parameters.""" + self.mock_backend = MagicMock(spec=Backend) + self.mock_parameters = MagicMock(spec=Parameters) + self.mock_thermal_limits = MagicMock(spec=ThermalLimits) + + # Define thermal limits + self.mock_thermal_limits.limits = np.array([100.0, 200.0]) + self.mock_thermal_limits.n_line = 2 + + # Default parameters + self.mock_parameters.SOFT_OVERFLOW_THRESHOLD = 1.0 + self.mock_parameters.HARD_OVERFLOW_THRESHOLD = 1.5 + self.mock_parameters.NB_TIMESTEP_OVERFLOW_ALLOWED = 3 + + # Backend behavior + self.mock_backend.get_line_status.return_value = np.array([True, True]) + self.mock_backend.get_line_flow.return_value = np.array([90.0, 210.0]) # One line is overflowing + self.mock_backend._runpf_with_diverging_exception.return_value = None + + # Initialize the DefaultProtection class + self.default_protection = DefaultProtection( + backend=self.mock_backend, + parameters=self.mock_parameters, + thermal_limits=self.mock_thermal_limits, + is_dc=False + ) + """ + soft overflow" : with two expected behaviour + either the line is on overflow for less than "NB_TIMESTEP_OVERFLOW_ALLOWED" (or a similar name not checked in the code) in this case the only consquence is that the overflow counter is increased by 1 + or the line has been on overflow for more than "NB_TIMESTEP_OVERFLOW_ALLOWED" in this case the line is disconnected + """ + def test_update_overflows_soft(self): + """Test for soft overflow.""" + lines_flows = np.array([90.0, 210.0]) + lines_to_disconnect = self.default_protection._update_overflows(lines_flows) + self.assertFalse(lines_to_disconnect[0]) # No disconnection for the first line + self.assertFalse(lines_to_disconnect[1]) # No disconnection yet, as the overflow is soft + + # il faut se relancer une deuxieme fois et une troiseieme fois pour le deconnecté =3 + + def test_update_overflows_hard(self): + """Test for hard overflow.""" + lines_flows = np.array([120.0, 310.0]) + lines_to_disconnect = self.default_protection._update_overflows(lines_flows) + self.assertFalse(lines_to_disconnect[0]) # The first line should not be disconnected + self.assertTrue(lines_to_disconnect[1]) # The second line should be disconnected + + def test_overflow_counter_increase(self): + """Test that the overflow counter does not exceed 1 per call.""" + self.mock_backend.get_line_flow.return_value = np.array([90.0, 210.0]) + self.default_protection._update_overflows(np.array([90.0, 210.0])) + self.assertEqual(self.default_protection._timestep_overflow[1], 1) # The overflow counter for line 1 should be 1 + + # Next call with different flow + self.mock_backend.get_line_flow.return_value = np.array([90.0, 220.0]) + self.default_protection._update_overflows(np.array([90.0, 220.0])) + self.assertEqual(self.default_protection._timestep_overflow[1], 2) # The overflow counter for line 1 should be 2 + + def test_initialization(self): + """Test the initialization of DefaultProtection.""" + self.assertIsInstance(self.default_protection, DefaultProtection) + self.assertEqual(self.default_protection.is_dc, False) + self.assertIsNotNone(self.default_protection._parameters) + + def test_validate_input(self): + """Test input validation.""" + with self.assertRaises(Grid2OpException): + DefaultProtection(backend=None, parameters=self.mock_parameters) + + def test_run_power_flow(self): + """Test running the power flow.""" + result = self.default_protection._run_power_flow() + self.assertIsNone(result) + + def test_update_overflows(self): + """Test updating overflows and lines to disconnect.""" + lines_flows = np.array([120.0, 310.0]) + lines_to_disconnect = self.default_protection._update_overflows(lines_flows) + self.assertTrue(lines_to_disconnect[1]) # Only the second line should be disconnected + self.assertFalse(lines_to_disconnect[0]) + + def test_disconnect_lines(self): + """Test disconnecting lines.""" + lines_to_disconnect = np.array([False, True]) + self.default_protection._disconnect_lines(lines_to_disconnect, timestep=1) + self.mock_backend._disconnect_line.assert_called_once_with(1) + + def test_next_grid_state(self): + """Test simulating the network's evolution.""" + disconnected, infos, error = self.default_protection.next_grid_state() + self.assertIsInstance(disconnected, np.ndarray) + self.assertIsInstance(infos, list) + self.assertIsNone(error) + + def test_no_protection(self): + """Test the NoProtection class.""" + no_protection = NoProtection(self.mock_backend, self.mock_thermal_limits) + disconnected, infos, conv = no_protection.next_grid_state() + self.assertIsInstance(disconnected, np.ndarray) + self.assertIsInstance(infos, list) + self.assertIsNone(conv) + +class TestFunctionalProtection(unittest.TestCase): + + def setUp(self): + """Initialization of mocks for a functional test.""" + self.mock_backend = MagicMock(spec=Backend) + self.mock_parameters = MagicMock(spec=Parameters) + self.mock_thermal_limits = MagicMock(spec=ThermalLimits) + + # Set thermal limits and line flows + self.mock_thermal_limits.limits = np.array([100.0, 200.0]) + self.mock_thermal_limits.n_line = 2 + self.mock_parameters.SOFT_OVERFLOW_THRESHOLD = 1.0 + self.mock_parameters.HARD_OVERFLOW_THRESHOLD = 1.5 + self.mock_parameters.NB_TIMESTEP_OVERFLOW_ALLOWED = 3 + + # Backend behavior to simulate line flows + self.mock_backend.get_line_status.return_value = np.array([True, True]) + self.mock_backend.get_line_flow.return_value = np.array([90.0, 210.0]) + self.mock_backend._runpf_with_diverging_exception.return_value = None + + # Initialize protection class with parameters + self.default_protection = DefaultProtection( + backend=self.mock_backend, + parameters=self.mock_parameters, + thermal_limits=self.mock_thermal_limits, + is_dc=False + ) + + # Initialize NoProtection + self.no_protection = NoProtection( + backend=self.mock_backend, + thermal_limits=self.mock_thermal_limits, + is_dc=False + ) + + def test_functional_default_protection(self): + """Functional test for DefaultProtection.""" + self.mock_backend.get_line_flow.return_value = np.array([90.0, 210.0]) # Lines with overflow + disconnected, infos, error = self.default_protection.next_grid_state() + + self.assertTrue(np.any(disconnected == -1)) # Line 1 should be disconnected + self.assertIsNone(error) + self.assertEqual(len(infos), 0) + + def test_functional_no_protection(self): + """Functional test for NoProtection.""" + self.mock_backend.get_line_flow.return_value = np.array([90.0, 180.0]) # No lines overflowing + disconnected, infos, error = self.no_protection.next_grid_state() + + self.assertTrue(np.all(disconnected == -1)) # No line should be disconnected + self.assertIsNone(error) + self.assertEqual(len(infos), 0) + +if __name__ == "__main__": + unittest.main() \ No newline at end of file diff --git a/grid2op/tests/test_thermalLimits.py b/grid2op/tests/test_thermalLimits.py new file mode 100644 index 00000000..1ed2033f --- /dev/null +++ b/grid2op/tests/test_thermalLimits.py @@ -0,0 +1,70 @@ +import unittest +import numpy as np +import logging +from grid2op.Backend.protectionScheme import ThermalLimits +from grid2op.Exceptions import Grid2OpException + +class TestThermalLimits(unittest.TestCase): + + def setUp(self): + """Initialization before each test.""" + self.logger = logging.getLogger("test_logger") + self.logger.disabled = True + + self.n_lines = 3 + self.line_names = ["Line1", "Line2", "Line3"] + self.thermal_limits = np.array([100.0, 200.0, 300.0]) + + self.thermal_limit_instance = ThermalLimits( + _thermal_limit_a=self.thermal_limits, + line_names=self.line_names, + n_line=self.n_lines, + logger=self.logger + ) + + def test_initialization(self): + """Test the initialization of ThermalLimits.""" + self.assertEqual(self.thermal_limit_instance.n_line, self.n_lines) + self.assertEqual(self.thermal_limit_instance.name_line, self.line_names) + np.testing.assert_array_equal(self.thermal_limit_instance.limits, self.thermal_limits) + + def test_set_n_line(self): + """Test the setter for n_line.""" + self.thermal_limit_instance.n_line = 5 + self.assertEqual(self.thermal_limit_instance.n_line, 5) + + with self.assertRaises(ValueError): + self.thermal_limit_instance.n_line = -1 # Should raise an error + + def test_set_name_line(self): + """Test the setter for name_line.""" + new_names = ["L4", "L5", "L6"] + self.thermal_limit_instance.name_line = new_names + self.assertEqual(self.thermal_limit_instance.name_line, new_names) + + with self.assertRaises(ValueError): + self.thermal_limit_instance.name_line = ["L4", 123, "L6"] # Should raise an error + + def test_set_limits(self): + """Test the setter for limits with np.array and dict.""" + new_limits = np.array([400.0, 500.0, 600.0]) + self.thermal_limit_instance.limits = new_limits + np.testing.assert_array_equal(self.thermal_limit_instance.limits, new_limits) + + limit_dict = {"Line1": 110.0, "Line2": 220.0, "Line3": 330.0} + self.thermal_limit_instance.limits = limit_dict + np.testing.assert_array_equal( + self.thermal_limit_instance.limits, np.array([110.0, 220.0, 330.0]) + ) + + with self.assertRaises(Grid2OpException): + self.thermal_limit_instance.limits = {"InvalidLine": 100.0} # Non-existent line + + def test_copy(self): + """Test the copy method.""" + copied_instance = self.thermal_limit_instance.copy() + self.assertIsNot(copied_instance, self.thermal_limit_instance) + np.testing.assert_array_equal(copied_instance.limits, self.thermal_limit_instance.limits) + +if __name__ == "__main__": + unittest.main()