diff --git a/grid2op/Environment/EnvInterface.py b/grid2op/Environment/EnvInterface.py new file mode 100644 index 00000000..468e0339 --- /dev/null +++ b/grid2op/Environment/EnvInterface.py @@ -0,0 +1,189 @@ +# Copyright (c) 2025, RTE (https://www.rte-france.com) +# See AUTHORS.txt +# This Source Code Form is subject to the terms of the Mozilla Public License, version 2.0. +# If a copy of the Mozilla Public License, version 2.0 was not distributed with this file, +# you can obtain one at http://mozilla.org/MPL/2.0/. +# SPDX-License-Identifier: MPL-2.0 +# This file is part of Grid2Op, Grid2Op a testbed platform to model sequential decision making in power systems. +from abc import ABC, abstractmethod +from typing import Tuple, Union + +from grid2op.Action import BaseAction +from grid2op.Observation import BaseObservation +from grid2op.typing_variables import STEP_INFO_TYPING, RESET_OPTIONS_TYPING + + +class EnvInterface(ABC): + """ + This is an interface for Grid2op environments designed to ensure that all implementations (except for multi-environments, + which have the same methods but slightly different signatures) define the minimum methods required to interact with + an environment. + """ + @abstractmethod + def reset(self, + *, + seed: Union[int, None] = None, + options: RESET_OPTIONS_TYPING = None) -> BaseObservation: + pass + + @abstractmethod + def step(self, action: BaseAction) -> Tuple[BaseObservation, + float, + bool, + STEP_INFO_TYPING]: + """ + Run one timestep of the environment's dynamics. When end of + episode is reached, you are responsible for calling `reset()` + to reset this environment's state. + Accepts an action and returns a tuple (observation, reward, done, info). + + If the :class:`grid2op.BaseAction.BaseAction` is illegal or ambiguous, the step is performed, but the action is + replaced with a "do nothing" action. + + Parameters + ---------- + action: :class:`grid2op.Action.Action` + an action provided by the agent that is applied on the underlying through the backend. + + Returns + ------- + observation: :class:`grid2op.Observation.Observation` + agent's observation of the current environment + + reward: ``float`` + amount of reward returned after previous action + + done: ``bool`` + whether the episode has ended, in which case further step() calls will return undefined results + + info: ``dict`` + contains auxiliary diagnostic information (helpful for debugging, and sometimes learning). It is a + dictionary with keys: + + - "disc_lines": a numpy array (or ``None``) saying, for each powerline if it has been disconnected + due to overflow (if not disconnected it will be -1, otherwise it will be a + positive integer: 0 meaning that is one of the cause of the cascading failure, 1 means + that it is disconnected just after, 2 that it's disconnected just after etc.) + - "is_illegal" (``bool``) whether the action given as input was illegal + - "is_ambiguous" (``bool``) whether the action given as input was ambiguous. + - "is_dispatching_illegal" (``bool``) was the action illegal due to redispatching + - "is_illegal_reco" (``bool``) was the action illegal due to a powerline reconnection + - "reason_alarm_illegal" (``None`` or ``Exception``) reason for which the alarm is illegal + (it's None if no alarm are raised or if the alarm feature is not used) + - "reason_alert_illegal" (``None`` or ``Exception``) reason for which the alert is illegal + (it's None if no alert are raised or if the alert feature is not used) + - "opponent_attack_line" (``np.ndarray``, ``bool``) for each powerline, say if the opponent + attacked it (``True``) or not (``False``). + - "opponent_attack_sub" (``np.ndarray``, ``bool``) for each substation, say if the opponent + attacked it (``True``) or not (``False``). + - "opponent_attack_duration" (``int``) the duration of the current attack (if any) + - "exception" (``list`` of :class:`Exceptions.Exceptions.Grid2OpException` if an exception was + raised or ``[]`` if everything was fine.) + - "detailed_infos_for_cascading_failures" (optional, only if the backend has been create with + `detailed_infos_for_cascading_failures=True`) the list of the intermediate steps computed during + the simulation of the "cascading failures". + - "rewards": dictionary of all "other_rewards" provided when the env was built. + - "time_series_id": id of the time series used (if any, similar to a call to `env.chronics_handler.get_id()`) + Examples + --------- + + This is used like: + + .. code-block:: python + + import grid2op + from grid2op.Agent import RandomAgent + + # I create an environment + env = grid2op.make("l2rpn_case14_sandbox") + + # define an agent here, this is an example + agent = RandomAgent(env.action_space) + + # environment need to be "reset" before usage: + obs = env.reset() + reward = env.reward_range[0] + done = False + + # now run through each steps like this + while not done: + action = agent.act(obs, reward, done) + obs, reward, done, info = env.step(action) + + Notes + ----- + + If the flag `done=True` is raised (*ie* this is the end of the episode) then the observation is NOT properly + updated and should not be used at all. + + Actually, it will be in a "game over" state (see :class:`grid2op.Observation.BaseObservation.set_game_over`). + + """ + pass + + def render(self, mode="rgb_array"): + """ + Render the state of the environment on the screen, using matplotlib + Also returns the Matplotlib figure + + Examples + -------- + Rendering need first to define a "renderer" which can be done with the following code: + + .. code-block:: python + + import grid2op + + # create the environment + env = grid2op.make("l2rpn_case14_sandbox") + + # if you want to use the renderer + env.attach_renderer() + + # and now you can "render" (plot) the state of the grid + obs = env.reset() + done = False + reward = env.reward_range[0] + while not done: + env.render() # this piece of code plot the grid + action = agent.act(obs, reward, done) + obs, reward, done, info = env.step(action) + """ + pass + + def close(self): + """close an environment: this will attempt to free as much memory as possible. + Note that after an environment is closed, you will not be able to use anymore. + + Any attempt to use a closed environment might result in non deterministic behaviour. + """ + pass + + def __enter__(self): + """ + Support *with-statement* for the environment. + + Examples + -------- + + .. code-block:: python + + import grid2op + import grid2op.BaseAgent + with grid2op.make("l2rpn_case14_sandbox") as env: + agent = grid2op.BaseAgent.DoNothingAgent(env.action_space) + act = env.action_space() + obs, r, done, info = env.step(act) + act = agent.act(obs, r, info) + obs, r, done, info = env.step(act) + + """ + return self + + def __exit__(self, *args): + """ + Support *with-statement* for the environment. + """ + self.close() + # propagate exception + return False diff --git a/grid2op/Environment/EnvRecorder.py b/grid2op/Environment/EnvRecorder.py new file mode 100644 index 00000000..6c8124b2 --- /dev/null +++ b/grid2op/Environment/EnvRecorder.py @@ -0,0 +1,266 @@ +# Copyright (c) 2025, RTE (https://www.rte-france.com) +# See AUTHORS.txt +# This Source Code Form is subject to the terms of the Mozilla Public License, version 2.0. +# If a copy of the Mozilla Public License, version 2.0 was not distributed with this file, +# you can obtain one at http://mozilla.org/MPL/2.0/. +# SPDX-License-Identifier: MPL-2.0 +# This file is part of Grid2Op, Grid2Op a testbed platform to model sequential decision making in power systems. +import json +from abc import ABC +from datetime import datetime +from pathlib import Path +from typing import Tuple, Union, Callable, List +try: + import pyarrow as pa + import pyarrow.parquet +except ImportError: + print("pyarrow is not installed. Please install it to use EnvRecorder.") + import sys + sys.exit(1) + + +from grid2op.Action import BaseAction +from grid2op.Environment.EnvInterface import EnvInterface +from grid2op.Observation import BaseObservation +from grid2op.Space import GRID2OP_CURRENT_VERSION_STR +from grid2op.typing_variables import STEP_INFO_TYPING, RESET_OPTIONS_TYPING + + +class AbstractTable(ABC): + """ + A class to accumulate, organize, and write objects data in a columnar format + to Parquet files. Designed to handle large-scale data efficiently, buffering + objects before writing in chunks. + + This class is intended to facilitate data management for time-stamped objects, + appending new objects vectors, and exporting them to disk efficiently to + reduce memory usage and improve disk I/O performance over time. + + Attributes + ---------- + _columns : List[str] + List of column names representing the structure of the object vector. + + _directory : Path + Path to the directory where the Parquet file will be stored. + + _table_name : str + Name of the output Parquet file (without the extension). + + _write_chunk_size : int + Number of rows to buffer before writing to the Parquet file. + + _buffer : List[List] + Internal buffer to temporarily store observation data before writing. + + _writer : Optional[pa.parquet.ParquetWriter] + Writer object to manage Parquet file I/O operations, lazy initialized. + + """ + def __init__(self, columns: List[str], directory: Path, table_name: str, write_chunk_size: int): + self._columns = columns + self._directory = directory + self._table_name = table_name + self._write_chunk_size = write_chunk_size + self._buffer = [[] for _ in range(len(columns) + 1)] + self._writer = None + + def reset(self): + self.close() # or with discard buffered data ? + + def _flush(self, force: bool): + if len(self._buffer[0]) > 0 and (force or len(self._buffer[0]) >= self._write_chunk_size): + table = pa.table(self._buffer, ['time'] + list(self._columns)) + if self._writer is None: + parquet_file = self._directory / f"{self._table_name}.parquet" + self._writer = pa.parquet.ParquetWriter(parquet_file, schema=table.schema) + self._writer.write_table(table) + self._buffer = [[] for _ in range(len(self._columns) + 1)] # reset buffer + + def close(self): + self._flush(True) + if self._writer is not None: + self._writer.close() + self._writer = None + + +ObservationVectorGetter = Callable[[BaseObservation], List[float]] + +class ObservationTable(AbstractTable): + + def __init__(self, columns: List[str], getter: ObservationVectorGetter, directory: Path, table_name: str, + write_chunk_size: int): + super().__init__(columns, directory, table_name, write_chunk_size) + self._getter = getter + + def append(self, obs: BaseObservation): + time = obs.get_time_stamp() + self._buffer[0].append(int(time.timestamp())) + + vec = self._getter(obs) + for i in range(len(self._columns)): + self._buffer[i + 1].append(vec[i]) + + self._flush(False) + + +class ActionTable(AbstractTable): + + def __init__(self, directory: Path, table_name: str, write_chunk_size: int): + super().__init__(['action', 'done'], directory, table_name, write_chunk_size) + + def append(self, time: datetime, act: BaseAction, done: bool): + self._buffer[0].append(int(time.timestamp())) + json_str = json.dumps(act.as_serializable_dict()) + self._buffer[1].append(json_str) + self._buffer[2].append(done) + self._flush(False) + + +class EnvRecorder(EnvInterface): + """ + An environment recorder for capturing and storing environment data. + + This class serves as a wrapper for a given environment and records its + observations into Parquet files for later analysis. It ensures that environment + data such as observations are properly stored in a structured format. + All the genrated Parquet files are stored in the configured output directory. + This class does not save simulation (obs.simulate) or forecast (obs.get_forecast_env) data. + + Attributes + ---------- + + _env : EnvInterface + The underlying environment to be wrapped and recorded. + + _tables : list of ObservationTable + A list of observation tables used to record specific environment + observations, such as generator power or load power. + + """ + def __init__(self, env, directory: Path, write_chunk_size: int = 1000): + super().__init__() + self._env = env + self._directory = directory + + # env general data + env_data = { + "grid2op_version": GRID2OP_CURRENT_VERSION_STR, + "env_grid2op_version": type(env).glop_version, + "name": env.name, + "path": self._env._init_env_path, + "backend": self._env.backend.__class__.__name__, + "n_sub": env.n_sub, + "n_busbar_per_sub": env.n_busbar_per_sub + } + with open(directory / "env.json", "w") as f: + json.dump(env_data, f, indent=4) + + # one table for each kind of element + self.write_element_table([env.name_gen, env.gen_type, env.gen_to_subid, env.gen_pos_topo_vect], ['name', 'type', 'gen_to_subid', 'gen_pos_topo_vect'], directory, 'gen') + self.write_element_table([env.name_load, env.load_to_subid, env.load_pos_topo_vect], ['name', 'load_to_subid', 'load_pos_topo_vect'], directory, 'load') + self.write_element_table([env.name_shunt, env.shunt_to_subid], ['name', 'shunt_to_subid'], directory, 'shunt') + self.write_element_table([env.name_storage, env.storage_to_subid, env.storage_pos_topo_vect], ['name', 'storage_to_subid', 'storage_pos_topo_vect'], directory, 'storage') + self.write_element_table([env.name_line, env.line_or_to_subid, env.line_ex_to_subid, env.line_or_pos_topo_vect, env.line_ex_pos_topo_vect], ['name', 'line_or_to_subid', 'line_ex_to_subid', 'line_or_pos_topo_vect', 'line_ex_pos_topo_vect'], directory, 'line') + + # one table per element attributs. + self._tables = [ + ObservationTable(self._env.name_gen, lambda obs: obs.gen_p_before_curtail, directory, 'gen_p_before_curtail', write_chunk_size), + ObservationTable(self._env.name_gen, lambda obs: obs.gen_p, directory, 'gen_p', write_chunk_size), + ObservationTable(self._env.name_gen, lambda obs: obs.gen_p_detached, directory, 'gen_p_detached', write_chunk_size), + ObservationTable(self._env.name_gen, lambda obs: obs.gen_q, directory, 'gen_q', write_chunk_size), + ObservationTable(self._env.name_gen, lambda obs: obs.gen_bus, directory, 'gen_bus', write_chunk_size), + ObservationTable(self._env.name_gen, lambda obs: obs.gen_detached, directory, 'gen_detached', write_chunk_size), + ObservationTable(self._env.name_gen, lambda obs: obs.gen_v, directory, 'gen_v', write_chunk_size), + ObservationTable(self._env.name_gen, lambda obs: obs.gen_theta, directory, 'gen_theta', write_chunk_size), + ObservationTable(self._env.name_gen, lambda obs: obs.actual_dispatch, directory, 'gen_actual_dispatch', write_chunk_size), + ObservationTable(self._env.name_gen, lambda obs: obs.target_dispatch, directory, 'gen_target_dispatch', write_chunk_size), + + ObservationTable(self._env.name_load, lambda obs: obs.load_p, directory, 'load_p', write_chunk_size), + ObservationTable(self._env.name_load, lambda obs: obs.load_p_detached, directory, 'load_p_detached', write_chunk_size), + ObservationTable(self._env.name_load, lambda obs: obs.load_q, directory, 'load_q', write_chunk_size), + ObservationTable(self._env.name_load, lambda obs: obs.load_q_detached, directory, 'load_q_detached', write_chunk_size), + ObservationTable(self._env.name_load, lambda obs: obs.load_bus, directory, 'load_bus', write_chunk_size), + ObservationTable(self._env.name_load, lambda obs: obs.load_v, directory, 'load_v', write_chunk_size), + ObservationTable(self._env.name_load, lambda obs: obs.load_theta, directory, 'load_theta', write_chunk_size), + + ObservationTable(self._env.name_shunt, lambda obs: obs._shunt_p, directory, 'shunt_p', write_chunk_size), + ObservationTable(self._env.name_shunt, lambda obs: obs._shunt_q, directory, 'shunt_q', write_chunk_size), + ObservationTable(self._env.name_shunt, lambda obs: obs._shunt_v, directory, 'shunt_v', write_chunk_size), + ObservationTable(self._env.name_shunt, lambda obs: obs._shunt_bus, directory, 'shunt_bus', write_chunk_size), + + ObservationTable(self._env.name_storage, lambda obs: obs.storage_power_target, directory, 'storage_power_target', write_chunk_size), + ObservationTable(self._env.name_storage, lambda obs: obs.storage_power, directory, 'storage_power', write_chunk_size), + ObservationTable(self._env.name_storage, lambda obs: obs.storage_charge, directory, 'storage_charge', write_chunk_size), + ObservationTable(self._env.name_storage, lambda obs: obs.storage_theta, directory, 'storage_theta', write_chunk_size), + ObservationTable(self._env.name_storage, lambda obs: obs.storage_detached, directory, 'storage_detached', write_chunk_size), + ObservationTable(self._env.name_storage, lambda obs: obs.storage_p_detached, directory, 'storage_p_detached', write_chunk_size), + ObservationTable(self._env.name_storage, lambda obs: obs.storage_bus, directory, 'storage_bus', write_chunk_size), + + ObservationTable(self._env.name_line, lambda obs: obs.line_or_bus, directory, 'line_or_bus', write_chunk_size), + ObservationTable(self._env.name_line, lambda obs: obs.line_ex_bus, directory, 'line_ex_bus', write_chunk_size), + ObservationTable(self._env.name_line, lambda obs: obs.line_status, directory, 'line_status', write_chunk_size), + ObservationTable(self._env.name_line, lambda obs: obs.p_or, directory, 'line_or_p', write_chunk_size), + ObservationTable(self._env.name_line, lambda obs: obs.p_ex, directory, 'line_ex_p', write_chunk_size), + ObservationTable(self._env.name_line, lambda obs: obs.q_or, directory, 'line_or_q', write_chunk_size), + ObservationTable(self._env.name_line, lambda obs: obs.q_ex, directory, 'line_ex_q', write_chunk_size), + ObservationTable(self._env.name_line, lambda obs: obs.a_or, directory, 'line_or_a', write_chunk_size), + ObservationTable(self._env.name_line, lambda obs: obs.a_ex, directory, 'line_ex_a', write_chunk_size), + ObservationTable(self._env.name_line, lambda obs: obs.v_or, directory, 'line_or_v', write_chunk_size), + ObservationTable(self._env.name_line, lambda obs: obs.v_ex, directory, 'line_ex_v', write_chunk_size), + ObservationTable(self._env.name_line, lambda obs: obs.theta_or, directory, 'line_or_theta', write_chunk_size), + ObservationTable(self._env.name_line, lambda obs: obs.theta_ex, directory, 'line_ex_theta', write_chunk_size), + ObservationTable(self._env.name_line, lambda obs: obs.rho, directory, 'line_rho', write_chunk_size), + ObservationTable(self._env.name_line, lambda obs: obs.thermal_limit, directory, 'line_thermal_limit', write_chunk_size) + ] + + self._actions_table = ActionTable(directory, 'actions', write_chunk_size) + + @staticmethod + def write_element_table(data, column_names, directory: Path, table_name: str): + element_table = pa.table({col: data[i] for i, col in enumerate(column_names)}) + pa.parquet.write_table(element_table, directory / f"{table_name}.parquet") + + @property + def env(self): + return self._env + + def reset(self, + *, + seed: Union[int, None] = None, + options: RESET_OPTIONS_TYPING = None) -> BaseObservation: + for table in self._tables: + table.reset() + + self._actions_table.reset() + + obs = self._env.reset(seed=seed, options=options) + self._append_obs(obs) + self._actions_table.append(obs.get_time_stamp(), self._env.action_space(), False) + return obs + + def _append_obs(self, obs: BaseObservation): + for table in self._tables: + table.append(obs) + + def step(self, action: BaseAction) -> Tuple[BaseObservation, + float, + bool, + STEP_INFO_TYPING]: + result = self._env.step(action) + done = result[2] + obs = result[0] + self._append_obs(obs) + self._actions_table.append(obs.get_time_stamp(), action, done) + return result + + def render(self, mode="rgb_array"): + self._env.render(mode=mode) + + def close(self): + for table in self._tables: + table.close() + + self._actions_table.close() + + self._env.close() diff --git a/grid2op/Environment/baseEnv.py b/grid2op/Environment/baseEnv.py index abf0e252..d4842fd0 100644 --- a/grid2op/Environment/baseEnv.py +++ b/grid2op/Environment/baseEnv.py @@ -24,6 +24,7 @@ from grid2op._glop_platform_info import _IS_WINDOWS from grid2op.Environment._env_prev_state import _EnvPreviousState +from grid2op.Environment.EnvInterface import EnvInterface from grid2op.Observation import (BaseObservation, ObservationSpace, HighResSimCounter) @@ -99,7 +100,7 @@ """ -class BaseEnv(GridObjects, RandomObject, ABC): +class BaseEnv(GridObjects, EnvInterface, RandomObject, ABC): """ INTERNAL @@ -239,11 +240,11 @@ def foo(manager): .. warning:: /!\\\\ Internal, do not use unless you know what you are doing /!\\\\ Current state of the delayed protection. It is exacly :attr:`BaseEnv._timestep_overflow` unless - :attr:`grid2op.Parameters.Parameters.SOFT_OVERFLOW_THRESHOLD` != 1. - - If the soft overflow threshold is different than 1, it counts the number of steps + :attr:`grid2op.Parameters.Parameters.SOFT_OVERFLOW_THRESHOLD` != 1. + + If the soft overflow threshold is different than 1, it counts the number of steps since the soft overflow threshold is "activated" (flow > limits * soft_overflow_threshold) - + _nb_ts_max_protection_counter: ``numpy.ndarray``, dtype: int .. warning:: /!\\\\ Internal, do not use unless you know what you are doing /!\\\\ @@ -688,11 +689,11 @@ def __init__( # slack (1.11.0) self._delta_gen_p = None - + # required in 1.11.0 : the previous state when the element was last connected self._previous_conn_state = None self._cst_prev_state_at_init = None - + # 1.11: do not check rules if first observation self._called_from_reset = True @@ -1024,8 +1025,8 @@ def _custom_deepcopy_for_copy(self, new_obj, dict_=None): # previous connected state new_obj._previous_conn_state = copy.deepcopy(self._previous_conn_state) new_obj._cst_prev_state_at_init = self._cst_prev_state_at_init # no need to deep copy this - - + + new_obj._called_from_reset = self._called_from_reset new_obj._needs_active_bus = self._needs_active_bus @@ -1500,7 +1501,7 @@ def _has_been_initialized(self): # slack (1.11.0) self._delta_gen_p = np.zeros(bk_type.n_gen, dtype=dt_float) - + # previous state (complete) n_shunt = bk_type.n_shunt if bk_type.shunts_data_available else 0 self._previous_conn_state = _EnvPreviousState(bk_type, @@ -1514,7 +1515,7 @@ def _has_been_initialized(self): np.zeros(n_shunt, dtype=dt_float), np.zeros(n_shunt, dtype=dt_int), ) - + if self._init_obs is None: # regular environment, initialized from scratch try: @@ -1533,7 +1534,7 @@ def _has_been_initialized(self): self._backend_action.last_topo_registered.values[:] = self._init_obs._prev_conn._topo_vect self._cst_prev_state_at_init = copy.deepcopy(self._init_obs._prev_conn) self._previous_conn_state.update_from_other(self._init_obs._prev_conn) - + self._cst_prev_state_at_init.prevent_modification() # update backend_action with the "last known" state self._backend_action.last_topo_registered.values[:] = self._previous_conn_state._topo_vect @@ -1645,7 +1646,7 @@ def _reset_slack_and_detachment(self): self._detached_elements_mw = 0. self._detached_elements_mw_prev = 0. - + def _reset_alert(self): self._last_alert[:] = False self._is_already_attacked[:] = False @@ -2257,7 +2258,7 @@ def _compute_dispatch_vect(self, already_modified_gen, new_p): self._target_dispatch[gen_participating] - self._actual_dispatch[gen_participating] ) - + already_modified_gen_me = already_modified_gen[gen_participating] target_vals_me = target_vals[already_modified_gen_me] nb_dispatchable = gen_participating.sum() @@ -2299,7 +2300,7 @@ def _compute_dispatch_vect(self, already_modified_gen, new_p): - self._sum_curtailment_mw + self._detached_elements_mw ) - + # gen increase in the chronics new_p_th = new_p[gen_participating] + self._actual_dispatch[gen_participating] @@ -3330,7 +3331,7 @@ def _aux_register_env_converged(self, # set to 0 the number of timestep for lines that are not on overflow self._timestep_overflow[~overflow_lines] = 0 - + # update protection counter engaged_protection = current_flows > self.backend.get_thermal_limit() * self._parameters.SOFT_OVERFLOW_THRESHOLD self._protection_counter[engaged_protection] += 1 @@ -3386,8 +3387,8 @@ def _aux_register_env_converged(self, return SomeGeneratorBelowRampmin(f"Especially generators {gen_ko_nms}") self._gen_activeprod_t[:] = tmp_gen_p - - # set the status of the other elements (if the backend + + # set the status of the other elements (if the backend # disconnect them) topo_ = self.backend.get_topo_vect() if cls.detachment_is_allowed: @@ -3399,7 +3400,7 @@ def _aux_register_env_converged(self, self.backend.update_bus_target_after_pf(topo_[cls.load_pos_topo_vect], topo_[cls.gen_pos_topo_vect], topo_[cls.storage_pos_topo_vect]) - + # problem with the gen_activeprod_t above, is that the slack bus absorbs alone all the losses # of the system. So basically, when it's too high (higher than the ramp) it can # mess up the rest of the environment @@ -3407,7 +3408,7 @@ def _aux_register_env_converged(self, # set the line status self._line_status[:] = self.backend.get_line_status() - + # for detachment remember previous loads and generation self._prev_load_p[:], self._prev_load_q[:], *_ = self.backend.loads_info() self._delta_gen_p[:] = self._gen_activeprod_t - self._gen_activeprod_t_redisp @@ -3417,10 +3418,10 @@ def _aux_register_env_converged(self, # finally, build the observation (it's a different one at each step, we cannot reuse the same one) # THIS SHOULD BE DONE AFTER EVERYTHING IS INITIALIZED ! self.current_obs = self.get_obs(_do_copy=False) - + # update the previous state self._previous_conn_state.update_from_backend(self.backend) - + self._time_extract_obs += time.perf_counter() - beg_res return None @@ -3470,7 +3471,7 @@ def _aux_run_pf_after_state_properly_set( ): has_error = True detailed_info = None - + try: # compute the next _grid state beg_pf = time.perf_counter() @@ -3500,20 +3501,20 @@ def _aux_run_pf_after_state_properly_set( def _aux_apply_detachment(self, new_p, new_p_th): gen_detached_user = self._backend_action.get_gen_detached() load_detached_user = self._backend_action.get_load_detached() - + # handle gen - mw_gen_lost_this = new_p[gen_detached_user].sum() - + mw_gen_lost_this = new_p[gen_detached_user].sum() + # handle loads - mw_load_lost_this = self._prev_load_p[load_detached_user].sum() - + mw_load_lost_this = self._prev_load_p[load_detached_user].sum() + # put everything together total_power_lost = -mw_gen_lost_this + mw_load_lost_this - self._detached_elements_mw = (-total_power_lost + - self._actual_dispatch[gen_detached_user].sum() - + self._detached_elements_mw = (-total_power_lost + + self._actual_dispatch[gen_detached_user].sum() - self._detached_elements_mw_prev) self._detached_elements_mw_prev = -total_power_lost - + # and now modifies the vectors new_p[gen_detached_user] = 0. new_p_th[gen_detached_user] = 0. @@ -3756,7 +3757,7 @@ def step(self, action: BaseAction) -> Tuple[BaseObservation, # it is feasible) self._gen_before_curtailment[cls.gen_renewable] = new_p[cls.gen_renewable] gen_curtailed = self._aux_handle_curtailment_without_limit(action, new_p) - + # TODO detachment self._aux_update_backend_action(action, action_storage_power, init_disp) new_p, new_p_th = self._aux_apply_detachment(new_p, new_p_th) @@ -3787,7 +3788,7 @@ def step(self, action: BaseAction) -> Tuple[BaseObservation, if not is_done: # TODO ? # self._aux_update_backend_action(action, action_storage_power, init_disp) - + # TODO storage: check the original action, even when replaced by do nothing is not modified self._backend_action += self._env_modification self._backend_action.set_redispatch(self._actual_dispatch) @@ -3976,41 +3977,7 @@ def _reset_maintenance(self): self._time_next_maintenance[:] = -1 self._duration_next_maintenance[:] = 0 - def __enter__(self): - """ - Support *with-statement* for the environment. - - Examples - -------- - - .. code-block:: python - - import grid2op - import grid2op.BaseAgent - with grid2op.make("l2rpn_case14_sandbox") as env: - agent = grid2op.BaseAgent.DoNothingAgent(env.action_space) - act = env.action_space() - obs, r, done, info = env.step(act) - act = agent.act(obs, r, info) - obs, r, done, info = env.step(act) - - """ - return self - - def __exit__(self, *args): - """ - Support *with-statement* for the environment. - """ - self.close() - # propagate exception - return False - def close(self): - """close an environment: this will attempt to free as much memory as possible. - Note that after an environment is closed, you will not be able to use anymore. - - Any attempt to use a closed environment might result in non deterministic behaviour. - """ if self.__closed: raise EnvError( f"This environment {id(self)} {self} is closed already, you cannot close it a second time." @@ -4355,7 +4322,7 @@ def fast_forward_chronics(self, nb_timestep, init_dt=None): raise EnvError("This environment is not intialized. " "Have you called `env.reset()` after last game over ?") nb_timestep = int(nb_timestep) - + # Go to the timestep requested minus one nb_timestep = max(1, nb_timestep - 1) self.chronics_handler.fast_forward(nb_timestep) diff --git a/grid2op/Environment/environment.py b/grid2op/Environment/environment.py index 44342481..c22bacfd 100644 --- a/grid2op/Environment/environment.py +++ b/grid2op/Environment/environment.py @@ -1091,29 +1091,29 @@ def reset(self, to ensure the episode is fully over. This method should be called only at the end of an episode. - + Parameters ---------- seed: int - The seed to used (new in version 1.9.8), see examples for more details. Ignored if not set (meaning no seeds will + The seed to used (new in version 1.9.8), see examples for more details. Ignored if not set (meaning no seeds will be used, experiments might not be reproducible) - + options: dict 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 + - "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 + - "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 + - "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 + + See examples for more information about this. Ignored if not set. - + Examples -------- The standard "gym loop" can be done with the following code: @@ -1133,102 +1133,102 @@ def reset(self, while not done: action = agent.act(obs, reward, done) obs, reward, done, info = env.step(action) - + .. versionadded:: 1.9.8 It is now possible to set the seed and the time series you want to use at the new episode by calling `env.reset(seed=..., options={"time serie id": ...})` - Before version 1.9.8, if you wanted to use a fixed seed, you would need to (see + Before version 1.9.8, if you wanted to use a fixed seed, you would need to (see doc of :func:`grid2op.Environment.BaseEnv.seed` ): - + .. code-block:: python seed = ... env.seed(seed) obs = env.reset() ... - + Starting from version 1.9.8 you can do this in one call: - + .. code-block:: python seed = ... - obs = env.reset(seed=seed) - + obs = env.reset(seed=seed) + For the "time series id" it is the same concept. Before you would need to do (see doc of :func:`Environment.set_id` for more information ): - + .. code-block:: python time_serie_id = ... env.set_id(time_serie_id) obs = env.reset() - ... - + ... + And now (from version 1.9.8) you can more simply do: - + .. code-block:: python time_serie_id = ... obs = env.reset(options={"time serie id": time_serie_id}) - ... - + ... + .. versionadded:: 1.10.2 - - Another feature has been added in version 1.10.2, which is the possibility to set the - grid to a given "topological" state at the first observation (before this version, - you could only retrieve an observation with everything connected together). - - In grid2op 1.10.2, you can do that by using the keys `"init state"` in the "options" kwargs of + + Another feature has been added in version 1.10.2, which is the possibility to set the + grid to a given "topological" state at the first observation (before this version, + you could only retrieve an observation with everything connected together). + + In grid2op 1.10.2, you can do that by using the keys `"init state"` in the "options" kwargs of the reset function. The value associated to this key should be dictionnary that can be converted to a non ambiguous grid2op action using an "action space". - + .. note:: The "action space" used here is not the action space of the agent. It's an "action space" that uses a :func:`grid2op.Action.Action.BaseAction` class meaning you can do any type of action, on shunts, on topology, on line status etc. even if the agent is not allowed to. - + Likewise, nothing check if this action is legal or not. - + You can use it like this: - + .. code-block:: python # to start an episode with a line disconnected, you can do: init_state_dict = {"set_line_status": [(0, -1)]} obs = env.reset(options={"init state": init_state_dict}) obs.line_status[0] is False - + # to start an episode with a different topolovy init_state_dict = {"set_bus": {"lines_or_id": [(0, 2)], "lines_ex_id": [(3, 2)]}} obs = env.reset(options={"init state": init_state_dict}) - + .. note:: Since grid2op version 1.10.2, there is also the possibility to set the "initial state" - of the grid directly in the time series. The priority is always given to the - argument passed in the "options" value. - + of the grid directly in the time series. The priority is always given to the + argument passed in the "options" value. + Concretely if, in the "time series" (formelly called "chronics") provides an action would change the topology of substation 1 and 2 (for example) and you provide an action that disable the line 6, then the initial state will see substation 1 and 2 changed (as in the time series) - and line 6 disconnected. - + and line 6 disconnected. + Another example in this case: if the action you provide would change topology of substation 2 and 4 then the initial state (after `env.reset`) will give: - + - substation 1 as in the time serie - substation 2 as in "options" - substation 4 as in "options" - + .. note:: Concerning the previously described behaviour, if you want to ignore the data in the time series, you can add : `"method": "ignore"` in the dictionary describing the action. In this case the action in the time series will be totally ignored and the initial state will be fully set by the action passed in the "options" dict. - + An example is: - + .. code-block:: python init_state_dict = {"set_line_status": [(0, -1)], "method": "force"} @@ -1236,94 +1236,94 @@ def reset(self, obs.line_status[0] is False .. versionadded:: 1.10.3 - + Another feature has been added in version 1.10.3, the possibility to skip the some steps of the time series and starts at some given steps. - + The time series often always start at a given day of the week (*eg* Monday) and at a given time (*eg* midnight). But for some reason you notice that your agent performs poorly on other day of the week or time of the day. This might be - because it has seen much more data from Monday at midnight that from any other + because it has seen much more data from Monday at midnight that from any other day and hour of the day. - + To alleviate this issue, you can now easily reset an episode and ask grid2op to start this episode after xxx steps have "passed". - + Concretely, you can do it with: - + .. code-block:: python import grid2op env_name = "l2rpn_case14_sandbox" env = grid2op.make(env_name) - + obs = env.reset(options={"init ts": 1}) - + Doing that your agent will start its episode not at midnight (which is the case for this environment), but at 00:05 - + If you do: - + .. code-block:: python - + obs = env.reset(options={"init ts": 12}) - + In this case, you start the episode at 01:00 and not at midnight (you start at what would have been the 12th steps) - + If you want to start the "next day", you can do: - + .. code-block:: python - + obs = env.reset(options={"init ts": 288}) - + etc. - + .. note:: - On this feature, if a powerline is on soft overflow (meaning its flow is above + On this feature, if a powerline is on soft overflow (meaning its flow is above the limit but below the :attr:`grid2op.Parameters.Parameters.HARD_OVERFLOW_THRESHOLD` * `the limit`) - then it is still connected (of course) and the counter + then it is still connected (of course) and the counter :attr:`grid2op.Observation.BaseObservation.timestep_overflow` is at 0. - - If a powerline is on "hard overflow" (meaning its flow would be above - :attr:`grid2op.Parameters.Parameters.HARD_OVERFLOW_THRESHOLD` * `the limit`), then, as it is + + If a powerline is on "hard overflow" (meaning its flow would be above + :attr:`grid2op.Parameters.Parameters.HARD_OVERFLOW_THRESHOLD` * `the limit`), then, as it is the case for a "normal" (without options) reset, this line is disconnected, but can be reconnected directly (:attr:`grid2op.Observation.BaseObservation.time_before_cooldown_line` == 0) - + .. seealso:: The function :func:`Environment.fast_forward_chronics` for an alternative usage (that will be deprecated at some point) - + Yet another feature has been added in grid2op version 1.10.3 in this `env.reset` function. It is the capacity to limit the duration of an episode. - + .. code-block:: python import grid2op env_name = "l2rpn_case14_sandbox" env = grid2op.make(env_name) - + obs = env.reset(options={"max step": 288}) This will limit the duration to 288 steps (1 day), meaning your agent will have successfully managed the entire episode if it manages to keep the grid in a safe state for a whole day (depending on the environment you are using the default duration is either one week - roughly 2016 steps or 4 weeks) - + .. note:: - This option only affect the current episode. It will have no impact on the + This option only affect the current episode. It will have no impact on the next episode (after reset) - + For example: - + .. code-block:: python - + obs = env.reset() obs.max_step == 8064 # default for this environment - + obs = env.reset(options={"max step": 288}) obs.max_step == 288 # specified by the option - + obs = env.reset() obs.max_step == 8064 # retrieve the default behaviour @@ -1331,29 +1331,29 @@ def reset(self, The function :func:`Environment.set_max_iter` for an alternative usage with the different 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 + 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. - + 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 @@ -1471,33 +1471,6 @@ def reset(self, return self.get_obs() def render(self, mode="rgb_array"): - """ - Render the state of the environment on the screen, using matplotlib - Also returns the Matplotlib figure - - Examples - -------- - Rendering need first to define a "renderer" which can be done with the following code: - - .. code-block:: python - - import grid2op - - # create the environment - env = grid2op.make("l2rpn_case14_sandbox") - - # if you want to use the renderer - env.attach_renderer() - - # and now you can "render" (plot) the state of the grid - obs = env.reset() - done = False - reward = env.reward_range[0] - while not done: - env.render() # this piece of code plot the grid - action = agent.act(obs, reward, done) - obs, reward, done, info = env.step(action) - """ # Try to create a plotter instance # Does nothing if viewer exists # Raises if matplot is not installed diff --git a/grid2op/tests/test_EnvRecorder.py b/grid2op/tests/test_EnvRecorder.py new file mode 100644 index 00000000..7ba41cb8 --- /dev/null +++ b/grid2op/tests/test_EnvRecorder.py @@ -0,0 +1,112 @@ +# Copyright (c) 2025, RTE (https://www.rte-france.com) +# See AUTHORS.txt +# This Source Code Form is subject to the terms of the Mozilla Public License, version 2.0. +# If a copy of the Mozilla Public License, version 2.0 was not distributed with this file, +# you can obtain one at http://mozilla.org/MPL/2.0/. +# SPDX-License-Identifier: MPL-2.0 +# This file is part of Grid2Op, Grid2Op a testbed platform to model sequential decision making in power systems. +import json +import unittest +import warnings +from pathlib import Path +from tempfile import TemporaryDirectory + +import pandas as pd + +import grid2op +from grid2op.Backend import PandaPowerBackend +from grid2op.Environment.EnvRecorder import EnvRecorder + + +class TestEnvRecorder(unittest.TestCase): + + @staticmethod + def make_backend(detailed_infos_for_cascading_failures=False): + return PandaPowerBackend(detailed_infos_for_cascading_failures) + + def test_recording(self): + with warnings.catch_warnings(): + warnings.filterwarnings("ignore") + env = grid2op.make( + "rte_case5_example", + test=True, + backend=self.make_backend(), + _add_to_name=type(self).__name__ + ) + with TemporaryDirectory() as tmp_dir_name: + tmp_dir_path = Path(tmp_dir_name) + with EnvRecorder(env, tmp_dir_path, 3) as env_rec: + env_rec.reset() + do_nothing = env.action_space() + done = False + while not done: + _, _, done, _ = env_rec.step(do_nothing) + + # check all files have been generated + for file_name in ['gen_detached.parquet', + 'line_ex_q.parquet', + 'storage.parquet', + 'storage_detached.parquet', + 'line_rho.parquet', + 'gen_p_before_curtail.parquet', + 'line_or_bus.parquet', + 'line_ex_theta.parquet', + 'load_q.parquet', + 'line_ex_a.parquet', + 'line_or_v.parquet', + 'line_or_q.parquet', + 'line_or_theta.parquet', + 'line_or_p.parquet', + 'storage_power.parquet', + 'load_p.parquet', + 'gen_theta.parquet', + 'line_ex_v.parquet', + 'shunt_bus.parquet', + 'line_ex_p.parquet', + 'storage_p_detached.parquet', + 'gen_bus.parquet', + 'line_thermal_limit.parquet', + 'load_p_detached.parquet', + 'gen_actual_dispatch.parquet', + 'shunt_q.parquet', + 'line_or_a.parquet', + 'gen_q.parquet', + 'storage_theta.parquet', + 'gen.parquet', + 'load_v.parquet', + 'gen_p.parquet', + 'load.parquet', + 'storage_power_target.parquet', + 'load_q_detached.parquet', + 'shunt_v.parquet', + 'line_status.parquet', + 'gen_target_dispatch.parquet', + 'shunt.parquet', + 'storage_charge.parquet', + 'env.json', + 'line.parquet', + 'load_theta.parquet', + 'storage_bus.parquet', + 'load_bus.parquet', + 'gen_v.parquet', + 'line_ex_bus.parquet', + 'gen_p_detached.parquet', + 'shunt_p.parquet', + 'actions.parquet']: + pq_file = tmp_dir_path / f"{file_name}" + assert pq_file.is_file() + + # check one of the table file content + gen_p_pq = pd.read_parquet(tmp_dir_path / "gen_p.parquet") + assert gen_p_pq.shape == (96, 3) + assert gen_p_pq.columns.tolist() == ['time', 'gen_0_0', 'gen_1_1'] + + # check the environment infos file content + with open(tmp_dir_path / "env.json", "r", encoding="utf-8") as f: + env_infos = json.load(f) + assert env_infos['grid2op_version'] + assert env_infos['name'] == 'rte_case5_examplePandaPowerBackendTestEnvRecorder' + assert env_infos['path'] + assert env_infos['backend'] == 'PandaPowerBackend_rte_case5_examplePandaPowerBackendTestEnvRecorder' + assert env_infos['n_sub'] == 5 + assert env_infos['n_busbar_per_sub'] == 2