diff --git a/.circleci/config.yml b/.circleci/config.yml index b5c496bce..717ef5d9d 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -28,7 +28,7 @@ executors: jobs: test: executor: grid2op-executor - resource_class: medium + resource_class: medium+ parallelism: 4 steps: - checkout @@ -151,8 +151,7 @@ jobs: - run: command: | source venv_test/bin/activate - python -m pip install -U "numpy>=1.24,<1.25" "pandas<2.2" "scipy<1.12" numba - python -m pip install -U .[test] + python -m pip install -U "numpy>=1.24,<1.25" "pandas<2.2" "scipy<1.12" numba .[test] - run: command: | source venv_test/bin/activate diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 310f61316..1e311d426 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -156,7 +156,7 @@ jobs: - name: Upload source archive uses: actions/upload-artifact@v2 - if: matrix.config.name == 'darwin' && matrix.python.name == 'cp39' + if: matrix.config.name == 'darwin' && matrix.python.name == 'cp310' with: name: grid2op-sources path: dist/*.tar.gz diff --git a/.gitignore b/.gitignore index 84e7e7bd5..e950fdba4 100644 --- a/.gitignore +++ b/.gitignore @@ -399,6 +399,10 @@ pp_bug_gen_alone.py test_dunder.py grid2op/tests/test_fail_ci.txt saved_multiepisode_agent_36bus_DN_4/ +grid2op/tests/requirements.txt +grid2op/tests/venv_test_311/ +issue_577/ +junk.py # profiling files **.prof diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 821c3365b..dcd6cd590 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -32,6 +32,51 @@ Change Log - [???] properly model interconnecting powerlines +[1.10.0] - 2024-03-06 +---------------------- +- [BREAKING] the order of the actions in `env.action_space.get_all_unitary_line_set` and + `env.action_space.get_all_unitary_topologies_set` might have changed (this is caused + by a rewriting of these functions in case there is not 2 busbars per substation) +- [FIXED] github CI did not upload the source files +- [FIXED] `l2rpn_utils` module did not stored correctly the order + of actions and observation for wcci_2020 +- [FIXED] 2 bugs detected by static code analysis (thanks sonar cloud) +- [FIXED] a bug in `act.get_gen_modif` (vector of wrong size was used, could lead + to some crashes if `n_gen >= n_load`) +- [FIXED] a bug in `act.as_dict` when shunts were modified +- [FIXED] a bug affecting shunts: sometimes it was not possible to modify their p / q + values for certain values of p or q (an AmbiguousAction exception was raised wrongly) +- [FIXED] a bug in the `_BackendAction`: the "last known topoolgy" was not properly computed + in some cases (especially at the time where a line was reconnected) +- [FIXED] `MultiDiscreteActSpace` and `DiscreteActSpace` could be the same classes + on some cases (typo in the code). +- [FIXED] a bug in `MultiDiscreteActSpace` : the "do nothing" action could not be done if `one_sub_set` (or `one_sub_change`) + was selected in `attr_to_keep` +- [ADDED] a method `gridobj.topo_vect_element()` that does the opposite of `gridobj.xxx_pos_topo_vect` +- [ADDED] a mthod `gridobj.get_powerline_id(sub_id)` that gives the + id of all powerlines connected to a given substation +- [ADDED] a convenience function `obs.get_back_to_ref_state(...)` + for the observation and not only the action_space. +- [IMPROVED] handling of "compatibility" grid2op version + (by calling the relevant things done in the base class + in `BaseAction` and `BaseObservation`) and by using the `from packaging import version` + to check version (instead of comparing strings) +- [IMPROVED] slightly the code of `check_kirchoff` to make it slightly clearer +- [IMRPOVED] typing and doc for some of the main classes of the `Action` module +- [IMRPOVED] typing and doc for some of the main classes of the `Observation` module +- [IMPROVED] methods `gridobj.get_lines_id`, `gridobj.get_generators_id`, `gridobj.get_loads_id` + `gridobj.get_storages_id` are now class methods and can be used with `type(env).get_lines_id(...)` + or `act.get_lines_id(...)` for example. +- [IMPROVED] `obs.get_energy_graph()` by giving the "local_bus_id" and the "global_bus_id" + of the bus that represents each node of this graph. +- [IMPROVED] `obs.get_elements_graph()` by giving access to the bus id (local, global and + id of the node) where each element is connected. +- [IMPROVED] description of the different graph of the grid in the documentation. +- [IMPROVED] type hints for the `gym_compat` module (more work still required in this area) +- [IMPROVED] the `MultiDiscreteActSpace` to have one "dimension" controling all powerlines + (see "one_line_set" and "one_line_change") +- [IMPROVED] doc at different places, including the addition of the MDP implemented by grid2op. + [1.9.8] - 2024-01-26 ---------------------- - [FIXED] the `backend.check_kirchoff` function was not correct when some elements were disconnected diff --git a/docs/_static/hacks.css b/docs/_static/hacks.css new file mode 100644 index 000000000..a0fa73de4 --- /dev/null +++ b/docs/_static/hacks.css @@ -0,0 +1,326 @@ +/* + * CSS hacks and small modification for my Sphinx website + * :copyright: Copyright 2013-2016 Lilian Besson + * :license: GPLv3, see LICENSE for details. + */ + + +/* Colors and text decoration. + For example, :black:`text in black` or :blink:`text blinking` in rST. */ + + .black { + color: black; +} + +.gray { + color: gray; +} + +.grey { + color: gray; +} + +.silver { + color: silver; +} + +.white { + color: white; +} + +.maroon { + color: maroon; +} + +.red { + color: red; +} + +.magenta { + color: magenta; +} + +.fuchsia { + color: fuchsia; +} + +.pink { + color: pink; +} + +.orange { + color: orange; +} + +.yellow { + color: yellow; +} + +.lime { + color: lime; +} + +.green { + color: green; +} + +.olive { + color: olive; +} + +.teal { + color: teal; +} + +.cyan { + color: cyan; +} + +.aqua { + color: aqua; +} + +.blue { + color: blue; +} + +.navy { + color: navy; +} + +.purple { + color: purple; +} + +.under { + text-decoration: underline; +} + +.over { + text-decoration: overline; +} + +.blink { + text-decoration: blink; +} + +.line { + text-decoration: line-through; +} + +.strike { + text-decoration: line-through; +} + +.it { + font-style: italic; +} + +.ob { + font-style: oblique; +} + +.small { + font-size: small; +} + +.large { + font-size: large; +} + +.smallpar { + font-size: small; +} + + +/* Style pour les badges en bas de la page. */ + +div.supportBadges { + margin: 1em; + text-align: right; +} + +div.supportBadges ul { + padding: 0; + display: inline; +} + +div.supportBadges li { + display: inline; +} + +div.supportBadges a { + margin-right: 1px; + opacity: 0.6; +} + +div.supportBadges a:hover { + opacity: 1; +} + + +/* Details elements in the sidebar */ + +a.reference { + border-bottom: none; + text-decoration: none; +} + +ul.details { + font-size: 80%; +} + +ul.details li p { + font-size: 85%; +} + +ul.externallinks { + font-size: 85%; +} + + +/* Pour le drapeau de langue */ + +img.languageswitch { + width: 50px; + height: 32px; + margin-left: 5px; + vertical-align: bottom; +} + +div.sphinxsidebar { + overflow: hidden !important; + font-size: 120%; + word-wrap: break-word; + width: 300px; + max-width: 300px; +} + +div.sphinxsidebar h3 { + font-size: 125%; +} + +div.sphinxsidebar h4 { + font-size: 110%; +} + +div.sphinxsidebar a { + font-size: 85%; +} + + +/* Image style for scrollUp jQuery plugin */ + +#scrollUpLeft { + bottom: 50px; + left: 260px; + height: 38px; + width: 38px; + background: url('//perso.crans.org/besson/_images/.top.svg'); + background: url('../_images/.top.svg'); +} + +@media screen and (max-width: 875px) { + #scrollUpLeft { + right: 50px; + left: auto; + } +} + + +/* responsive for font-size. */ + +@media (max-width: 875px) { + body { + font-size: 105%; + /* Increase font size for responsive theme */ + } +} + +@media (max-width: 1480px) and (min-width: 876px) { + body { + font-size: 110%; + /* Increase font size for not-so-big screens */ + } +} + +@media (min-width: 1481px) { + body { + font-size: 115%; + /* Increase even more font size for big screens */ + } +} + + +/* Social Icons in the sidebar (available: twitter, facebook, linkedin, google+, bitbucket, github) */ + +.social-icons { + display: inline-block; + margin: 0; + text-align: center; +} + +.social-icons a { + background: none no-repeat scroll center top #444444; + border: 1px solid #F6F6F6; + border-radius: 50% 50% 50% 50%; + display: inline-block; + height: 35px; + width: 35px; + margin: 0; + text-indent: -9000px; + transition: all 0.2s ease 0s; + text-align: center; + border-bottom: none; +} + +.social-icons li { + display: inline-block; + list-style-type: none; + border-bottom: none; +} +.social-icons li a { + border-bottom: none; +} + +.social-icons a:hover { + background-color: #666666; + transition: all 0.2s ease 0s; + text-decoration: none; +} + +.social-icons a.facebook { + background-image: url('../_images/.facebook.png'); + background-image: url('//perso.crans.org/besson/_images/.facebook.png'); + display: block; + margin-left: auto; + margin-right: auto; + background-size: 35px 35px; +} + +.social-icons a.bitbucket { + background-image: url('../_images/.bitbucket.png'); + background-image: url('//perso.crans.org/besson/_images/.bitbucket.png'); + display: block; + margin-left: auto; + margin-right: auto; + background-size: 35px 35px; +} + +.social-icons li a.github { + background-image: url('../_images/.github.png'); + background-image: url('//perso.crans.org/besson/_images/.github.png'); + display: block; + margin-left: auto; + margin-right: auto; + background-size: 35px 35px; +} + +.social-icons li a.wikipedia { + background-image: url('../_images/.wikipedia.png'); + background-image: url('//perso.crans.org/besson/_images/.wikipedia.png'); + display: block; + margin-left: auto; + margin-right: auto; + background-size: 35px 35px; +} \ No newline at end of file diff --git a/docs/action.rst b/docs/action.rst index 90abdaa57..b2c842f50 100644 --- a/docs/action.rst +++ b/docs/action.rst @@ -29,6 +29,11 @@ Action =================================== +This page is organized as follow: + +.. contents:: Table of Contents + :depth: 3 + Objectives ---------- The "Action" module lets you define some actions on the underlying power _grid. @@ -411,7 +416,7 @@ As we explained in the previous paragraph, some action on one end of a powerline powerline or disconnect it. This means they modify the bus of **both** the extremity of the powerline. Here is a table summarizing how the buses are impacted. We denoted by "`PREVIOUS_OR`" the last bus at which -the origin end of the powerline was connected and "`PREVIOUS_EX`" the last bus at which the extremity end of the +the origin side of the powerline was connected and "`PREVIOUS_EX`" the last bus at which the extremity side of the powerline was connected. Note that for clarity when something is not modified by the action we decided to write on the table "not modified" (this entails that after this action, if the powerline is connected then "new origin bus" is "`PREVIOUS_OR`" and "new extremity bus" is "`PREVIOUS_EX`"). We remind the reader that "-1" encode for a @@ -448,8 +453,35 @@ Easier actions manipulation ---------------------------- The action class presented here can be quite complex to apprehend, especially for a machine learning algorithm. -It is possible to use the :class:`grid2op.Converter` class for such purpose. You can have a look at the dedicated -documentation. +Grid2op offers some more "convient" manipulation of the powergrid by transforming this rather "descriptive" +action formulation to "action_space" that are compatible with Farama Fundation Gymnasium package ( +package that was formerly "openAI gym"). + +This includes: + +- :class:`grid2op.gym_compat.GymActionSpace` which "represents" actions as a gymnasium + `Dict `_ +- :class:`grid2op.gym_compat.BoxGymActSpace` which represents actions as gymnasium + `Box `_ + (actions are numpy arrays). This is especially suited for continuous attributes + such as redispatching, storage or curtailment. +- :class:`grid2op.gym_compat.DiscreteActSpace` which represents actions as gymnasium + `Discrete `_ + (actions are integer). This is especially suited for discrete actions such as + setting line status or topologies at substation. +- :class:`grid2op.gym_compat.MultiDiscreteActSpace` which represents actions as gymnasium + `MultiDiscrete `_ + (actions are integer). This is also especially suited for discrete actions such as + setting line status or topologies at substation. + +.. note:: + The main difference between :class:`grid2op.gym_compat.DiscreteActSpace` and + :class:`grid2op.gym_compat.MultiDiscreteActSpace` is that Discrete actions will + allow the agent to perform only one type of action at each step (either it performs + redispatching on one generator OR on another generator OR it set the status of a powerline + OR it set the substation at one substation etc. but it cannot "peform redispatching on + 2 or more generators" nor can it "perform redispatching on one generator AND disconnect a powerline") + which can be rather limited for some applications. Detailed Documentation by class diff --git a/docs/backend.rst b/docs/backend.rst index d4e666861..1d52adccd 100644 --- a/docs/backend.rst +++ b/docs/backend.rst @@ -1,4 +1,5 @@ .. currentmodule:: grid2op.Backend + .. _backend-module: Backend @@ -22,9 +23,39 @@ Objectives Both can serve as example if you want to code a new backend. This Module defines the template of a backend class. -Backend instances are responsible to translate action (performed either by an BaseAgent or by the Environment) into -comprehensive powergrid modifications. -They are responsible to perform the powerflow (AC or DC) computation. + +Backend instances are responsible to translate action into +comprehensive powergrid modifications that can be process by your "Simulator". +The simulator is responsible to perform the powerflow (AC or DC or Time Domain / Dynamic / Transient simulation) +and to "translate back" the results (of the simulation) to grid2op. + +More precisely, a backend should: + +#. inform grid2op of the grid: which objects exist, where are they connected etc. +#. being able to process an object of type :class:`grid2op.Action._backendAction._BackendAction` + into some modification to your solver (*NB* these "BackendAction" are created by the :class:`grid2op.Environment.BaseEnv` + from the agent's actions, the time series modifications, the maintenances, the opponent, etc. The backend **is not** + responsible for their creation) +#. being able to run a simulation (DC powerflow, AC powerflow or time domain / transient / dynamic) +#. expose (through some functions like :func:`Backend.generators_info` or :func:`Backend.loads_info`) + the state of some of the elements in the grid. + +.. note:: + A backend can model more elements than what can be controlled or modified in grid2op. + For example, at time of writing, grid2op does not allow the modification of + HVDC powerlines. But this does not mean that grid2op will not work if your grid + counts such devices. It just means that grid2op will not be responsible + for modifying them. + +.. note:: + A backend can expose only part of the grid to the environment / agent. For example, if you + give it as input a pan european grid but only want to study the grid of Netherlands or + France your backend can only "inform" grid2op (in the :func:`Backend.load_grid` function) + that "only the Dutch (or French) grid" exists and leave out all other informations. + + In this case grid2op will perfectly work, agents and environment will work as expected and be + able to control the Dutch (or French) part of the grid and your backend implementation + can control the rest (by directly updating the state of the solver). It is also through the backend that some quantities about the powergrid (such as the flows) can be inspected. @@ -57,6 +88,9 @@ We developed a dedicated page for the development of new "Backend" compatible wi Detailed Documentation by class ------------------------------- + +Then the `Backend` module: + .. automodule:: grid2op.Backend :members: :private-members: diff --git a/docs/chronics.rst b/docs/chronics.rst index 428852556..8a13f5674 100644 --- a/docs/chronics.rst +++ b/docs/chronics.rst @@ -1,7 +1,9 @@ .. currentmodule:: grid2op.Chronics -Chronics -=================================== +.. _time-series-module: + +Time series (formerly called "chronics") +========================================= This page is organized as follow: diff --git a/docs/conf.py b/docs/conf.py index 133a9a84b..46ea0ff96 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -22,7 +22,7 @@ author = 'Benjamin Donnot' # The full version, including alpha/beta/rc tags -release = '1.9.8' +release = '1.9.9.dev0' version = '1.9' @@ -75,6 +75,7 @@ # relative to this directory. They are copied after the builtin static files, # so a file named "default.css" will overwrite the builtin "default.css". html_static_path = ['_static'] +html_css_files = ['hacks.css'] # for pdf pdf_documents = [('index', u'rst2pdf', u'Grid2op documentation', u'B. DONNOT'),] diff --git a/docs/create_an_environment.rst b/docs/create_an_environment.rst index f802ad9c7..e0e36f8d0 100644 --- a/docs/create_an_environment.rst +++ b/docs/create_an_environment.rst @@ -8,6 +8,15 @@ Possible workflow to create an environment from existing time series ====================================================================== +This page is organized as follow: + +.. contents:: Table of Contents + :depth: 3 + + +Workflow in more details +------------------------- + In this subsection, we will give an example on how to set up an environment in grid2op if you already have some data that represents loads and productions at each steps. This paragraph aims at making more concrete the description of the environment shown previously. diff --git a/docs/createbackend.rst b/docs/createbackend.rst index c4746f6d9..db767c277 100644 --- a/docs/createbackend.rst +++ b/docs/createbackend.rst @@ -89,7 +89,9 @@ everywhere). This includes, but is not limited to: - etc. .. note:: Grid2Op do not care about the modeling of the grid (static / steady state or dyanmic / transient) and both - types of solver could be implemented as backend. At time of writing (december 2020), only steady state powerflow are + Any types of solver could be implemented as backend. + + At time of writing (december 2020), only steady state powerflow are available. .. note:: The previous note entails that grid2op is also independent on the format used to store a powergrid. @@ -131,7 +133,31 @@ everywhere). This includes, but is not limited to: Main methods to implement -------------------------- Typically, a backend has a internal "modeling" / "representation" of the powergrid -stored in the attribute `self._grid` that can be anything. An more detailed example, with some +stored in the attribute `self._grid` that can be anything. + +.. note:: + `self._grid` is a "private" attribute. Only people that knows what it does and how + it works should be able to use it. + + Grid2op being fully generic, you can assume that all the classes of grid2op will never + access `self._grid`. For example, when building the observation of the grid, + grid2op will only use the information given in the `*_infos()` methods + (*eg* :func:`grid2op.Backend.Backend.loads_info`) and never by directly accessing `self._grid` + + In other words, `self._grid` can be anything: a `PandaPower `_ `Network`, a + `GridCal `_ `MultiCircuit`, + a `lightsim2grid `_ `GridModel`, a + `pypowsybl `_ `Network` (or `SortedNetwork`), + a `powerfactory ` `Project` etc. + Grid2op will never attempt to access `self._grid` + + (Though, to be perfectly honest, some agents might rely on some type `_grid`, if that's the case, too + bad for these agents they will need to implement special methods to be compatible with your backend. + Hopefully this should be extremely rare... The whole idea of grid2op being to make the different + "entities" (agent, environment, data, backend) as independant as possible this "corner" cases should + be rare.) + +An more detailed example, with some "working minimal code" is given in the "example/backend_integration" of the grid2op repository. There are 4 **__main__** types of method you need to implement if you want to use a custom powerflow @@ -172,8 +198,9 @@ There are 4 **__main__** types of method you need to implement if you want to us .. _grid-description: -Grid description ------------------- +load_grid: Grid description +---------------------------- + In this section we explicit what attributes need to be implemented to have a valid backend instance. We focus on the attribute of the `Backend` you have to set. But don't forget you also need to load a powergrid and store it in the `_grid` attribute. @@ -184,18 +211,16 @@ Basically the `load_grid` function would look something like: def load_grid(self, path=None, filename=None): # simply handles different way of inputing the data - if path is None and filename is None: - raise RuntimeError("You must provide at least one of path or file to load a powergrid.") - if path is None: - full_path = filename - elif filename is None: - full_path = path - else: - full_path = os.path.join(path, filename) - if not os.path.exists(full_path): - raise RuntimeError("There is no powergrid at \"{}\"".format(full_path)) - - # load the grid in your favorite format: + full_path = self.make_complete_path(path, filename) + + # from grid2op 1.10.0 you need to call one of + self.can_handle_more_than_2_busbar() # see doc for more information + OR + self.cannot_handle_more_than_2_busbar() # see doc for more information + # It is important you include it at the top of this method, otherwise you + # will not have access to self.n_busbar_per_sub + + # load the grid in your favorite format, located at `full_path`: self._grid = ... # the way you do that depends on the "solver" you use # and now initialize the attributes (see list bellow) @@ -233,7 +258,7 @@ Name See paragraph Type Size Description `line_ex_to_subid`_ :ref:`subid` vect, int `n_line`_ For each powerline, it gives the substation id to which its **extremity** end is connected `name_load`_ vect, str `n_load`_ (optional) name of each load on the grid [if not set, by default it will be "load_$LoadSubID_$LoadID" for example "load_1_10" if the load with id 10 is connected to substation with id 1] `name_gen`_ vect, str `n_gen`_ (optional) name of each generator on the grid [if not set, by default it will be "gen_$GenSubID_$GenID" for example "gen_2_42" if the generator with id 42 is connected to substation with id 2] -`name_line`_ vect, str `n_line`_ (optional) name of each powerline (and transformers !) on the grid [if not set, by default it will be "$SubOrID_SubExID_LineID" for example "1_4_57" if the powerline with id 57 has its origin end connected to substation with id 1 and its extremity end connected to substation with id 4] +`name_line`_ vect, str `n_line`_ (optional) name of each powerline (and transformers !) on the grid [if not set, by default it will be "$SubOrID_SubExID_LineID" for example "1_4_57" if the powerline with id 57 has its origin side connected to substation with id 1 and its extremity side connected to substation with id 4] `name_sub`_ vect, str `n_sub`_ (optional) name of each substation on the grid [if not set, by default it will be "sub_$SubID" for example "sub_41" for the substation with id 41] `sub_info`_ :ref:`sub-i` vect, int `n_sub`_ (can be automatically set if you don't initialize it) For each substation, it gives the number of elements connected to it ("elements" here denotes: powerline - and transformer- ends, load or generator) `dim_topo`_ :ref:`sub-i` int NA (can be automatically set if you don't initialize it) Total number of elements on the grid ("elements" here denotes: powerline - and transformer- ends, load or generator) @@ -298,7 +323,7 @@ extremely complex way to say you have to do this: Note the number for each element in the substation. In this example, for substaion with id 0 (bottom left) you decided -that the powerline with id 0 (connected at this substation at its origin end) will be the "first object of this +that the powerline with id 0 (connected at this substation at its origin side) will be the "first object of this substation". Then the "Load 0" is the second object [remember index a 0 based, so the second object has id 1], generator 0 is the third object of this substation (you can know it with the "3" near it) etc. @@ -422,12 +447,12 @@ First, have a look at substation 0: You know that, at this substation 0 there are `6` elements connected. In this example, these are: -- origin end of Line 0 +- origin side of Line 0 - Load 0 - gen 0 -- origin end of line 1 -- origin end of line 2 -- origin end of line 3 +- origin side of line 1 +- origin side of line 2 +- origin side of line 3 Given that, you can fill: @@ -452,12 +477,12 @@ You defined (in a purely arbitrary manner): So you get: -- first component of `line_or_to_sub_pos` is 0 [because "origin end of line 0" is "element 0" of this substation] +- first component of `line_or_to_sub_pos` is 0 [because "origin side of line 0" is "element 0" of this substation] - first component of `load_to_sub_pos` is 1 [because "load 0" is "element 1" of this substation] - first component of `gen_to_sub_pos` is 2 [because "gen 0" is "element 2" of this substation] -- fourth component of `line_or_to_sub_pos` is 3 [because "origin end of line 3" is "element 3" of this substation] -- third component of `line_or_to_sub_pos` is 4 [because "origin end of line 2" is "element 4" of this substation] -- second component of `line_or_to_sub_pos` is 5 [because "origin end of line 1" is "element 5" of this substation] +- fourth component of `line_or_to_sub_pos` is 3 [because "origin side of line 3" is "element 3" of this substation] +- third component of `line_or_to_sub_pos` is 4 [because "origin side of line 2" is "element 4" of this substation] +- second component of `line_or_to_sub_pos` is 5 [because "origin side of line 1" is "element 5" of this substation] This is showed in the figure below: @@ -490,12 +515,12 @@ of your implementation of `load_grid` function) .. _backend-action-create-backend: -BackendAction: modification +apply_action: underlying grid modification ---------------------------------------------- In this section we detail step by step how to understand the specific format used by grid2op to "inform" the backend on how to modify its internal state before computing a powerflow. -A `BackendAction` will tell the backend on what is modified among: +A :class:`grid2op.Action._backendAction._BackendAction` will tell the backend on what is modified among: - the active value of each loads (see paragraph :ref:`change-inj`) - the reactive value of each loads (see paragraph :ref:`change-inj`) @@ -557,22 +582,22 @@ At the end, the `apply_action` function of the backend should look something lik ... # the way you do that depends on the `internal representation of the grid` lines_or_bus = backendAction.get_lines_or_bus() for line_id, new_bus in lines_or_bus: - # modify the "busbar" of the origin end of powerline line_id + # modify the "busbar" of the origin side of powerline line_id if new_bus == -1: - # the origin end of powerline is disconnected in the action, disconnect it on your internal representation of the grid + # the origin side of powerline is disconnected in the action, disconnect it on your internal representation of the grid ... # the way you do that depends on the `internal representation of the grid` else: - # the origin end of powerline is moved to either busbar 1 (in this case `new_bus` will be `1`) + # the origin side of powerline is moved to either busbar 1 (in this case `new_bus` will be `1`) # or to busbar 2 (in this case `new_bus` will be `2`) ... # the way you do that depends on the `internal representation of the grid` lines_ex_bus = backendAction.get_lines_ex_bus() for line_id, new_bus in lines_ex_bus: - # modify the "busbar" of the extremity end of powerline line_id + # modify the "busbar" of the extremity side of powerline line_id if new_bus == -1: - # the extremity end of powerline is disconnected in the action, disconnect it on your internal representation of the grid + # the extremity side of powerline is disconnected in the action, disconnect it on your internal representation of the grid ... # the way you do that depends on the `internal representation of the grid` else: - # the extremity end of powerline is moved to either busbar 1 (in this case `new_bus` will be `1`) + # the extremity side of powerline is moved to either busbar 1 (in this case `new_bus` will be `1`) # or to busbar 2 (in this case `new_bus` will be `2`) ... # the way you do that depends on the `internal representation of the grid` @@ -672,8 +697,8 @@ And of course you do the same for generators and both ends of each powerline. .. _vector-orders-create-backend: -Read back the results (flows, voltages etc.) ------------------------------------------------ +***_infos() : Read back the results (flows, voltages etc.) +-------------------------------------------------------------- This last "technical" part concerns what can be refer to as "getters" from the backend. These functions allow to read back the state of the grid and expose its results to grid2op in a standardize manner. @@ -774,7 +799,7 @@ And you do chat for all substations, giving: So in this simple example, the first element of the topology vector will represent the origin of powerline 0, the second element will represent the load 0, the 7th element (id 6, remember python index are 0 based) represent -first element of substation 1, so in this case extremity end of powerline 3, the 8th element the generator 1, etc. +first element of substation 1, so in this case extremity side of powerline 3, the 8th element the generator 1, etc. up to element with id 20 whith is the last element of the last substation, in this case extremity of powerline 7. Once you know the order, the encoding is pretty straightforward: @@ -957,10 +982,26 @@ TODO this will be explained "soon". Detailed Documentation by class ------------------------------- -.. autoclass:: grid2op.Backend.EducPandaPowerBackend.EducPandaPowerBackend +A first example of a working backend that can be easily understood (without nasty gory speed optimization) +based on pandapower is available at : + +.. autoclass:: grid2op.Backend.educPandaPowerBackend.EducPandaPowerBackend + :members: + :private-members: + :special-members: + :autosummary: + +And to understand better some key concepts, you can have a look at :class:`grid2op.Action._backendAction._BackendAction` +or the :class:`grid2op.Action._backendAction.ValueStore` class: + +.. autoclass:: grid2op.Action._backendAction._BackendAction :members: :private-members: :special-members: :autosummary: -.. include:: final.rst \ No newline at end of file +.. autoclass:: grid2op.Action._backendAction.ValueStore + :members: + :autosummary: + +.. include:: final.rst diff --git a/docs/data_pipeline.rst b/docs/data_pipeline.rst index cb86a6723..1792e834b 100644 --- a/docs/data_pipeline.rst +++ b/docs/data_pipeline.rst @@ -3,6 +3,14 @@ Optimize the data pipeline ============================ +This page is organized as follow: + +.. contents:: Table of Contents + :depth: 3 + +Objectives +-------------------------- + Optimizing the data pipeline can be crucial if you want to learn fast, especially at the beginning of the training. There exists multiple way to perform this task. diff --git a/docs/dive_into_time_series.rst b/docs/dive_into_time_series.rst index acf95f813..5a5264996 100644 --- a/docs/dive_into_time_series.rst +++ b/docs/dive_into_time_series.rst @@ -5,6 +5,14 @@ Input data of an environment =================================== +This page is organized as follow: + +.. contents:: Table of Contents + :depth: 3 + +Objectives +---------------- + A grid2op "environment" is nothing more than a local folder on your computer. This folder consists of different things: diff --git a/docs/environment.rst b/docs/environment.rst index 88213ffec..b40c1483b 100644 --- a/docs/environment.rst +++ b/docs/environment.rst @@ -1,4 +1,5 @@ .. currentmodule:: grid2op.Environment + .. _environment-module: Environment @@ -105,10 +106,10 @@ impact then you might consult the :ref:`environment-module-data-pipeline` page o .. _environment-module-chronics-info: -Chronics Customization -+++++++++++++++++++++++ +Time series Customization +++++++++++++++++++++++++++ -Study always the same chronics +Study always the same time serie ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ If you spotted a particularly interesting chronics, or if you want, for some reason your agent to see only one chronics, you can do this rather easily with grid2op. diff --git a/docs/episode.rst b/docs/episode.rst index 34bc8453e..9d8be3d8f 100644 --- a/docs/episode.rst +++ b/docs/episode.rst @@ -1,5 +1,6 @@ Episode =================================== + This page is organized as follow: .. contents:: Table of Contents diff --git a/docs/grid_graph.rst b/docs/grid_graph.rst index 8d2834cfa..c9733b2cc 100644 --- a/docs/grid_graph.rst +++ b/docs/grid_graph.rst @@ -10,6 +10,15 @@ A grid, a graph: grid2op representation of the powergrid =================================================================== + +This page is organized as follow: + +.. contents:: Table of Contents + :depth: 3 + +Objectives +---------------- + In this section of the documentation, we will dive a deeper into the "modeling" on which grid2op is based and especially how the underlying graph of the powergrid is represented and how it can be easily retrieved. @@ -23,10 +32,6 @@ First, we detail some concepts from the power system community in section :ref:`graph-encoding-gridgraph`. Finally, we show some code examples on how to retrieve this graph in section :ref:`get-the-graph-gridgraph`. - -.. contents:: Table of Contents - :depth: 3 - .. _powersystem-desc-gridgraph: Description of a powergrid adopting the "energy graph" representation @@ -321,11 +326,13 @@ To know what element of the grid is the "42nd", you can: case the extremity side of powerline `line_id`. 2) look at the table :attr:`grid2op.Space.GridObjects.grid_objects_types` and especially the line 42 so `env.grid_objects_types[42,:]` which contains this information as well. Each column of this table encodes - for one type of element (first column is substation, second is load, then generator, then origin end of - powerline then extremity end of powerline and finally storage unit. Each will have "-1" if the element + for one type of element (first column is substation, second is load, then generator, then origin side of + powerline then extremity side of powerline and finally storage unit. Each will have "-1" if the element is not of that type, and otherwise and id > 0. Taking the same example as for the above bullet point! `env.grid_objects_types[42,:] = [sub_id, -1, -1, -1, line_id, -1]` meaning the "42nd" element of the grid - if the extremity end (because it's the 5th column) of id `line_id` (the other element being marked as "-1"). + if the extremity side (because it's the 5th column) of id `line_id` (the other element being marked as "-1"). +3) refer to the :func:`grid2op.Space.GridObject.topo_vect_element` for an "easier" way to retrieve information + about this element. .. note:: As of a few versions of grid2op, if you are interested at the busbar to which (say) load 5 is connected, then Instead @@ -363,15 +370,15 @@ Type of graph described in grid2op method And their respective properties: -======================== ================ ======================== ===================== -Type of graph always same size encode all observation has flow information -======================== ================ ======================== ===================== -"energy graph" no almost yes -"elements graph" yes for nodes yes yes -"connectivity graph" yes no no -"bus connectivity graph" no no no -"flow bus graph" no no yes -======================== ================ ======================== ===================== +======================== =================== ==================== ======================= ===================== +Type of graph same number of node same number of edges encode all observation has flow information +======================== =================== ==================== ======================= ===================== +"energy graph" no no almost yes +"elements graph" yes no yes yes +"connectivity graph" yes no no no +"bus connectivity graph" no no no no +"flow bus graph" no no no yes +======================== =================== ==================== ======================= ===================== .. _graph1-gg: @@ -505,7 +512,7 @@ the two red powerlines, another where there are the two green) .. note:: On this example, for this visualization, lots of elements of the grid are not displayed. This is the case - for the load, generator and storage units for example. + for the loads, generators and storage units for example. For an easier to read representation, feel free to consult the :ref:`grid2op-plot-module` @@ -516,10 +523,10 @@ Graph2: the "elements graph" As opposed to the previous graph, this one has a fixed number of **nodes**: each nodes will represent an "element" of the powergrid. In this graph, there is -`n_sub` nodes each representing a substation and `2 * n_sub` nodes, each +`n_sub` nodes each representing a substation and `env.n_busbar_per_sub * n_sub` nodes, each representing a "busbar" and `n_load` nodes each representing a load etc. In total, there is then: -`n_sub + 2*n_sub + n_load + n_gen + n_line + n_storage + n_shunt` nodes. +`n_sub + env.n_busbar_per_sub*n_sub + n_load + n_gen + n_line + n_storage + n_shunt` nodes. Depending on its type, a node can have different properties. @@ -619,15 +626,16 @@ There are no outgoing edges from substation. Bus properties +++++++++++++++++++++++ -The next `2 * n_sub` nodes of the "elements graph" represent the "buses" of the grid. They have the attributes: +The next `env.n_busbar_per_sub * n_sub` nodes of the "elements graph" represent the "buses" of the grid. They have the attributes: -- `id`: which bus does this node represent (global id: `0 <= id < 2*env.n_sub`) +- `id`: which bus does this node represent (global id: `0 <= id < env.n_busbar_per_sub*env.n_sub`) - `global_id`: same as "id" -- `local_id`: which bus (in the substation) does this busbar represents (local id: `1 <= local_id <= 2`) +- `local_id`: which bus (in the substation) does this busbar represents (local id: `1 <= local_id <= env.n_busbar_per_sub`) - `type`: always "bus" - `connected`: whether or not this bus is "connected" to the grid. - `v`: the voltage magnitude of this bus (in kV, optional only when the bus is connected) -- `theta`: the voltage angle of this bus (in deg, optional only when the bus is connected) +- `theta`: the voltage angle of this bus (in deg, optional only when the bus is connected and + if the backend supports it) The outgoing edges from the nodes representing buses tells at which substation this bus is connected. These edges are "fixed": if they are present (meaning the bus is connected) they always connect the bus to the same substation. They have only @@ -645,7 +653,14 @@ The next `n_load` nodes of the "elements graph" represent the "loads" of the gri - `id`: which load does this node represent (between 0 and `n_load - 1`) - `type`: always "loads" - `name`: the name of this load (equal to `obs.name_load[id]`) -- `connected`: whether or not this load is connected to the grid. +- `connected`: whether or not this load is connected to the grid +- `local_bus`: (from version 1.9.9) the id (local, so between `1, 2, ..., obs.n_busbar_per_sub`) + of the bus to which this load is connected +- `global_bus`: (from version 1.9.9) the id (global, so between `0, 1, ..., obs.n_busbar_per_sub * obs.n_sub`) + of the bus to which this load is connected +- `bus_node_id`: (from version 1.9.9) the id of the node of this graph representing the bus to which the + load is connected. This means that if the load is connected, then (node_load_id, bus_node_id) is the + outgoing edge in this graph. The outgoing edges from the nodes representing loads tell at which bus this load is connected (for each load, there is only one outgoing edge). They have attributes: @@ -676,6 +691,13 @@ The next `n_gen` nodes of the "elements graph" represent the "generators" of the - `curtailment_limit`: same as `obs.curtailment_limit[id]`, see :attr:`grid2op.Observation.BaseObservation.curtailment_limit` - `gen_margin_up`: same as `obs.gen_margin_up[id]`, see :attr:`grid2op.Observation.BaseObservation.gen_margin_up` - `gen_margin_down`: same as `obs.gen_margin_down[id]`, see :attr:`grid2op.Observation.BaseObservation.gen_margin_down` +- `local_bus`: (from version 1.9.9) the id (local, so between `1, 2, ..., obs.n_busbar_per_sub`) + of the bus to which this generator is connected +- `global_bus`: (from version 1.9.9) the id (global, so between `0, 1, ..., obs.n_busbar_per_sub * obs.n_sub`) + of the bus to which this generator is connected +- `bus_node_id`: (from version 1.9.9) the id of the node of this graph representing the bus to which the + generator is connected. This means that if the generator is connected, then (node_gen_id, bus_node_id) is the + outgoing edge in this graph. The outgoing edges from the nodes representing generators tell at which bus this generator is connected (for each generator, there is only one outgoing edge). They have attributes: @@ -740,6 +762,14 @@ The next `n_storage` nodes represent the storage units. They have attributes: - `connected`: whether or not this storage unit is connected to the grid2op - `storage_charge`: same as `obs.storage_charge[id]`, see :attr:`grid2op.Observation.BaseObservation.storage_charge` - `storage_power_target`: same as `obs.storage_power_target[id]`, see :attr:`grid2op.Observation.BaseObservation.storage_power_target` +- `local_bus`: (from version 1.9.9) the id (local, so between `1, 2, ..., obs.n_busbar_per_sub`) + of the bus to which this storage unit is connected +- `global_bus`: (from version 1.9.9) the id (global, so between `0, 1, ..., obs.n_busbar_per_sub * obs.n_sub`) + of the bus to which this storage unit is connected +- `bus_node_id`: (from version 1.9.9) the id of the node of this graph representing the bus to which the + storage unit is connected. This means that if the storage unit is connected, + then (node_storage_id, bus_node_id) is the + outgoing edge in this graph. The outgoing edges from the nodes representing storage units tells at which bus this load is connected (for each load, there is only one outgoing edge). They have attributes: @@ -759,6 +789,14 @@ The next `n_shunt` nodes represent the storage units. They have attributes: - `type`: always "shunt" - `name`: the name of this shunt (equal to `obs.name_shunt[id]`) - `connected`: whether or not this shunt is connected to the grid2op +- `local_bus`: (from version 1.9.9) the id (local, so between `1, 2, ..., obs.n_busbar_per_sub`) + of the bus to which this shunt is connected +- `global_bus`: (from version 1.9.9) the id (global, so between `0, 1, ..., obs.n_busbar_per_sub * obs.n_sub`) + of the bus to which this shunt is connected +- `bus_node_id`: (from version 1.9.9) the id of the node of this graph representing the bus to which the + shunt is connected. This means that if the shunt is connected, + then (node_shunt_id, bus_node_id) is the + outgoing edge in this graph. The outgoing edges from the nodes representing sthuns tell at which bus this shunt is connected (for each load, there is only one outgoing edge). They have attributes: @@ -773,9 +811,25 @@ there is only one outgoing edge). They have attributes: Graph3: the "connectivity graph" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ -TODO: Work in progress, any help welcome +This graph is represented by a matrix (numpy 2d array or sicpy sparse matrix) of +floating point: `0.` means there are no connection between the elements and `1.`. + +Each row / column of the matrix represent an element modeled in the `topo_vect` vector. To know +more about the element represented by the row / column, you can have a look at the +:func:`grid2op.Space.GridObjects.topo_vect_element` function. -In the mean time, some documentation are available at :func:`grid2op.Observation.BaseObservation.connectivity_matrix` +In short, this graph gives the information of "this object" and "this other object" are connected +together: either they are the two side of the same powerline or they are connected to the same bus +in the grid. + +In other words the `node` of this graph are the element of the grid (side of line, load, gen and storage) +and the `edge` of this non oriented (undirected / symmetrical) non weighted graph represent the connectivity +of the grid. + +It has a fixed number of nodes (number of elements is fixed) but the number of edges can vary. + +You can consult the documentation of the :func:`grid2op.Observation.BaseObservation.connectivity_matrix` +for complement of information an some examples on how to retrieve this graph. .. note:: @@ -788,9 +842,24 @@ In the mean time, some documentation are available at :func:`grid2op.Observation Graph4: the "bus connectivity graph" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ -TODO: Work in progress, any help welcome -In the mean time, some documentation are available at :func:`grid2op.Observation.BaseObservation.bus_connectivity_matrix` +This graph is represented by a matrix (numpy 2d array or sicpy sparse matrix) of +floating point: `0.` means there are no connection between the elements and `1.`. + +As opposed to the previous "graph" the row / column of this matrix has as many elements as the number of +independant buses on the grid. There are 0. if no powerlines connects the two buses +or one if at least a powerline connects these two buses. + +In other words the `nodes` of this graph are the buse of the grid +and the `edges` of this non oriented (undirected / symmetrical) non weighted graph represent the presence +of powerline connected two buses (basically if there are line with one of its side connecting one of the bus +and the other side connecting the other). + +It has a variable number of nodes and edges. In case of game over we chose to represent this graph as +an graph with 1 node and 0 edge. + +You can consult the documentation of the :func:`grid2op.Observation.BaseObservation.bus_connectivity_matrix` +for complement of information an some examples on how to retrieve this graph. .. note:: @@ -804,9 +873,25 @@ In the mean time, some documentation are available at :func:`grid2op.Observation Graph5: the "flow bus graph" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ -TODO: Work in progress, any help welcome +This graph is also represented by a matrix (numpy 2d array or scipy sparse matrix) of float. It is quite similar +to the graph described in :ref:`graph4-gg`. The main difference is that instead of simply giving +information about connectivity (0. or 1.) this one gives information about flows +(either active flows or reactive flows). + +It is a directed graph (matrix is not symmetric) and it has weights. The weight associated to each node +(representing a bus) is the power (in MW for active or MVAr for reactive) injected at this bus +(generator convention: if the power is positive the power is injected at this graph). The weight associated +at each edge going from `i` to `j` is the sum of the active (or reactive) power of all +the lines connecting bus `i` to bus `j`. + +It has a variable number of nodes and edges. In case of game over we chose to represent this graph as +an graph with 1 node and 0 edge. + +You can consult the documentation of the :func:`grid2op.Observation.BaseObservation.flow_bus_matrix` +for complement of information an some examples on how to retrieve this graph. + +It is a simplified version of the :ref:`graph1-gg` described previously. -In the mean time, some documentation are available at :func:`grid2op.Observation.BaseObservation.flow_bus_matrix` .. note:: diff --git a/docs/index.rst b/docs/index.rst index 751b37b11..31dd1f648 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -94,6 +94,7 @@ Modeling :maxdepth: 2 :caption: Models + mdp modeled_elements grid_graph diff --git a/docs/makeenv.rst b/docs/makeenv.rst index 8fb895cb4..55184f7a7 100644 --- a/docs/makeenv.rst +++ b/docs/makeenv.rst @@ -80,11 +80,13 @@ It has the following behavior: it will be used (see section :ref:`usage`) 2) if you specify the name of an environment that you have already downloaded, it will use this environment (NB currently no checks are implemented if the environment has been updated remotely, which can happen if - we realize there were some issues with it.) + we realize there were some issues with it.). If you want to update the environments you downloaded + please use :func:`grid2op.update_env()` 3) you are expected to provide an environment name (if you don't know what this is just put `"l2rpn_case14_sandbox"`) 4) if the flag `test` is set to ``False`` (default behaviour) and none of the above conditions are met, the :func:`make` will download the data of this environment locally the first time it is called. If you don't want - to download anything then you can pass the flag ``test=True`` + to download anything then you can pass the flag ``test=True`` (in this case only a small sample of + time series will be available. We don't recommend to do that at all !) 5) if ``test=True`` (NON default behaviour) nothing will be loaded, and the :func:`make` will attempt to use a pre defined environment provided with the python package. We want to emphasize that because the environments provided with this package contains only little data, they are not suitable for leaning a consistent agent / controler. That @@ -134,11 +136,16 @@ context of the L2RPN competition, we don't recommend to modify them. - `dataset_path`: used to specify the name (or the path) of the environment you want to load - `backend`: a initialized backend that will carry out the computation related to power system [mainly use if you want - to change from PandapowerBackend (default) to a different one *eg* LightSim2Grid) -- `reward_class`: change the type of reward you want to use for your agent -- `other_reward`: tell "env.step" to return addition "rewards" + to change from PandapowerBackend (default) to a different one *eg* LightSim2Grid] +- `reward_class`: change the type of reward you want to use for your agent (see section + :ref:`reward-module` for more information). +- `other_reward`: tell "env.step" to return addition "rewards"(see section + :ref:`reward-module` for more information). - `difficulty`, `param`: control the difficulty level of the game (might not always be available) -- `chronics_class`, `data_feeding_kwargs`: further customization to how the data will be generated +- `chronics_class`, `data_feeding_kwargs`: further customization to how the data will be generated, + see section :ref:`environment-module-data-pipeline` for more information +- `n_busbar`: (``int``, default 2) [new in version 1.9.9] see section :ref:`substation-mod-el` + for more information - \* `chronics_path`, `data_feeding`, : to overload default path for the data (**not recommended**) - \* `action_class`: which action class your agent is allowed to use (**not recommended**). - \* `gamerules_class`: the rules that are checked to declare an action legal / illegal (**not recommended**) diff --git a/docs/mdp.rst b/docs/mdp.rst new file mode 100644 index 000000000..64e6ed46d --- /dev/null +++ b/docs/mdp.rst @@ -0,0 +1,861 @@ +.. include:: special.rst + +.. _mdp-doc-module: + +Dive into grid2op sequential decision process +=============================================== + +This page is organized as follow: + +.. contents:: Table of Contents + :depth: 3 + +Objectives +----------- + +The goal of this page of the documentation is to provide you with a relatively extensive description of the +mathematical model behind grid2op. + +Grid2op is a software whose aim is to make experiments on powergrid, mainly sequential decision making, +as easy as possible. + +We chose to model this sequential decision making probleme as a +"*Markov Decision Process*" (MDP) and one some cases +"*Partially Observable Markov Decision Process*" (POMDP) or +"*Constrainted Markov Decision Process*" (CMDP) and (work in progress) even +"*Decentralized (Partially Observable) Markov Decision Process*" (Dec-(PO)MDP). + +General notations +~~~~~~~~~~~~~~~~~~~~ + +There are different ways to define an MDP. In this paragraph we introduce the notations that we will use. + +In an MDP an "agent" / "automaton" / "algorithm" / "policy" takes some action :math:`a_t \in \mathcal{A}`. This +action is processed by the environment and update its internal state from :math:`s_t \in \mathcal{S}` +to :math:`s_{t+1} \in \mathcal{S}` and +computes a so-called *reward* :math:`r_{t+1} \in [0, 1]`. + +.. note:: + By stating the dynamic of the environment this way, we ensure the "*Markovian*" property: the + state :math:`s_{t+1}` is determined by the knowledge of the previous state :math:`s_{t}` and the + action :math:`a_{t}` + +This tuple +:math:`(s_t, r_t)` is then given to the "agent" / "automaton" / "algorithm" which in turns produce the action :math:`a_{t+1}` + +.. note:: + More formally even, everything written can be stochastic: + + - :math:`a_t \sim \pi_{\theta}(s_t)` where :math:`\pi_{\theta}(\cdot)` is the "policy" parametrized by + some parameters :math:`\theta` that outputs here a probability distribution (depending on the + state of the environment :math:`s_t`) over all the actions `\mathcal{A}` + - :math:`s_{t+1} \sim \mathcal{L}_S(s_t, a_t)` where :math:`\mathcal{L}_S(s_t, a_t)` is a probability distribution + over :math:`\mathcal{S}` representing the likelyhood if the "next state" given the current state and the action + of the "policy" + - :math:`r_{t+1} \sim \mathcal{L}_R(s_t, s_{t+1}, a_t)` is the reward function indicating "how good" + was the transition from :math:`s_{t}` to :math:`s_{t+1}` by taking action :math:`a_t` + + +This alternation :math:`\dots \to a \to (s, r) \to a \to \dots` is done for a certain number of "steps" called :math:`T`. + +We will call the list :math:`s_{1} \to a_1 \to (s_2, r_2) \to \dots \to a_{T-1} \to (s_{T}, r_T)` +an "**episode**". + +Formally the knowledge of: + +- :math:`\mathcal{S}`, the "state space" +- :math:`\mathcal{A}`, the "action space" +- :math:`\mathcal{L}_s(s, a)`, sometimes called "transition kernel", is the probability + distribution (over :math:`\mathcal{S}`) that gives the next + state after taking action :math:`a` in state :math:`s` +- :math:`\mathcal{L}_r(s, s', a)`, sometimes called "reward kernel", + is the probability distribution (over :math:`[0, 1]`) that gives + the reward :math:`r` after taking action :math:`a` in state :math:`s` which lead to state :math:`s'` +- :math:`T \in \mathbb{N}^*` the maximum number of steps for an episode + +Defines a MDP. We will detail all of them in the section :ref:`mdp-def` bellow. + +In grid2op, there is a special case where a grid state cannot be computed (either due to some physical infeasibilities +or because the resulting state would be irrealistic). This can be modeled relatively easily in the MDP formulation +above if we add a "terminal state" :math:`s_{\emptyset}` in the state space :math:`\mathcal{S}_{new} := \mathcal{S} \cup \left\{ s_{\emptyset} \right\}`: and add the transitions: +:math:`\mathcal{L}_s(s_{\emptyset}, a) = \text{Dirac}(s_{\emptyset}) \forall a \in \mathcal{A}` +stating that once the agent lands in this "terminal state" then the game is over, it stays there until the +end of the scenario. + +We can also define the reward kernel in this state, for example with +:math:`\mathcal{L}_r(s_{\emptyset}, s', a) = \text{Dirac}(0) \forall s' \in \mathcal{S}, a \in \mathcal{A}` and +:math:`\mathcal{L}_r(s, s_{\emptyset}, a) = \text{Dirac}(0) \forall s \in \mathcal{S}, a \in \mathcal{A}` which +states that there is nothing to be gained in being in this terminal set. + +Unless specified otherwise, we will not enter these details in the following explanation and take it as +"pre requisite" as it can be defined in general. We will focus on the definition of :math:`\mathcal{S}`, +:math:`\mathcal{A}`, :math:`\mathcal{L}_s(s, a)` and :math:`\mathcal{L}_r(s, s', a)` by leaving out the +"terminal state". + +.. note:: + In grid2op implementation, this "terminal state" is not directly implemented. Instead, the first Observation leading + to this state is marked as "done" (flag `obs.done` is set to `True`). + + No other "observation" will be given by + grid2op after an observation with `obs.done` set to `True` and the environment needs to be "reset". + + This is consistent with the gymnasium implementation. + +The main goal of a finite horizon MDP is then to find a policy :math:`\pi \in \Pi` that given states :math:`s` and reward :math:`r` +output an action :math:`a` such that (*NB* here :math:`\Pi` denotes the set of all considered policies for this +MDP): + +.. math:: + :nowrap: + + \begin{align*} + \min_{\pi \in \Pi} ~& \sum_{t=1}^T \mathbb{E} \left( r_t \right) \\ + \text{s.t.} ~ \\ + & \forall t, a_t \sim \pi (s_{t}) & \text{policy produces the action} \\ + & \forall t, s_{t+1} \sim \mathcal{L}_S(s_t, a_t) & \text{environment produces next state} \\ + & \forall t, r_{t+1} \sim \mathcal{L}_r(s_t, a_t, s_{t+1}) & \text{environment produces next reward} \\ + \end{align*} + +Specific notations +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +To define "the" MDP modeled by grid2op, we also need to define some other concepts that will be used to define the +state space :math:`\mathcal{S}` or transition kernel :math:`\mathcal{L}_s(s, a)` for example. + +A Simulator +++++++++++++ + +We need a so called "simulator". + +Informatically, this is represented by the `Backend` inside the grid2op environment (more information +about the `Backend` is detailed in the :ref:`backend-module` section of the documentation). + +This simulator is able to compute some informations that are part of the state +space :math:`\mathcal{S}` (*eg* flows on powerlines, active production value of generators etc.) +and thus are used in the computation of the transition kernel. + +We can model this simulator with a function :math:`\text{Sim}` that takes as input some data from an +"input space" :math:`\mathcal{S}_{\text{im}}^{(\text{in})}` and result +in data in :math:`\mathcal{S}_{\text{im}}^{(\text{out})}`. + +.. note:: + In grid2op we don't force the "shape" of :math:`\mathcal{S}_{\text{im}}^{(\text{in})}`, including + the format used to read the grid file from the hard drive, the solved equations, the way + these equations are used. Everything here is "free" and grid2op only needs that the simulator + (wrapped in a `Backend`) understands the "format" sent by grid2op (through a + :class:`grid2op.Action._backendAction._BackendAction`) and is able to expose + to grid2op some of its internal variables (accessed with the `***_infos()` methods of the backend) + + +TODO do I emphasize that the simulator also contains the grid iteself ? + +To make a parallel with similar concepts "simulator", +represents the physics as in all `"mujoco" environments `_ +*eg* `Ant `_ or +`Inverted Pendulum `_ . This is the same concept +here excepts that it solves powerflows. + +Some Time Series ++++++++++++++++++ + +Another type of data that we need to define "the" grid2op MDP is the "time series", implemented in the `chronics` +grid2op module documented on the page +:ref:`time-series-module` with some complements given in the :ref:`doc_timeseries` page as well. + +These time series define what exactly would happen if the grid was a +"copper plate" without any constraints. Said differently it provides what would each consumer +consume and what would each producer produce if they could all be connected together with +infinite "bandwith", without any constraints on the powerline etc. + +In particular, grid2op supposes that these "time series" are balanced, in the sense that the producers +produce just the right amount (electrical power cannot really be stocked) for the consumer to consume +and that for each steps. It also supposes that all the "constraints" of the producers. + +These time series are typically generated outside of grid2op, for example using `chronix2grid `_ +python package (or anything else). + + +Formally, we will define these time series as input :math:`\mathcal{X}_t` all these time series at time :math:`t`. These +exogenous data consist of : + +- generator active production (in MW), for each generator +- load active power consumption (in MW), for each loads +- load reactive consumption (in MVAr), for each loads +- \* generator voltage setpoint / target (in kV) + +.. note:: + \* for this last part, this can be adapted "on demand" by the environment through the `voltage controler` module. + But for the sake of modeling, this can be modeled as being external / exogenous data. + +And, to make a parrallel with similar concept in other RL environment, these "time series" can represent the layout of the maze +in pacman, the positions of the platforms in "mario-like" 2d games, the different turns and the width of the route in a car game etc. +This is the "base" of the levels in most games. + +Finally, for most released environment, a lof of different :math:`\mathcal{X}` are available. By default, each time the +environment is "reset" (the user want to move to the next scenario), a new :math:`\mathcal{X}` is used (this behaviour +can be changed, more information on the section :ref:`environment-module-chronics-info` of the documentation). + +.. _mdp-def: + +Modeling sequential decisions +------------------------------- + +As we said in introduction of this page, we will model a given scenario in grid2op. We have at our disposal: + +- a simulator, which is represented as a function :math:`\text{Sim} : \mathcal{S}_{\text{im}}^{(\text{in})} \to \mathcal{S}_{\text{im}}^{(\text{out})}` +- some time series :math:`\mathcal{X} = \left\{ \mathcal{X}_t \right\}_{1 \leq t \leq T}` + +In order to define the MDP we need to define: + +- :math:`\mathcal{S}`, the "state space" +- :math:`\mathcal{A}`, the "action space" +- :math:`\mathcal{L}_s(s, a)`, sometimes called "transition kernel", is the probability + distribution (over :math:`\mathcal{S}`) that gives the next + state after taking action :math:`a` in state :math:`s` +- :math:`\mathcal{L}_r(s, s', a)`, sometimes called "reward kernel", + is the probability distribution (over :math:`[0, 1]`) that gives + the reward :math:`r` after taking action :math:`a` in state :math:`s` which lead to state :math:`s'` + +We will do that for a single episode (all episodes follow the same process) + +Precisions +~~~~~~~~~~~ + +To make the reading of this MDP easier, for this section of the documentation, +we adopted the following convention: + +- text in :green:`green` will refer to elements that are read directly from the grid + by the simulator :math:`\text{Sim}` at the creation of the environment. +- text in :orange:`orange` will refer to elements that are related to time series :math:`\mathcal{X}` +- text in :blue:`blue` will refer to elements that can be + be informatically modified by the user at the creation of the environment. + +In the pure definition of the MDP all text in :green:`green`, :orange:`orange` or +:blue:`blue` are exogenous and constant: once the episode starts they cannot be changed +by anything (including the agent). + +We differenciate between these 3 types of "variables" only to clarify what can be modified +by "who": + +- :green:`green` variables depend only on the controlled powergrid +- :orange:`orange` variables depend only time series +- :blue:`blue` variables depend only on the way the environment is loaded + +.. note:: + Not all these variables are independant though. If there are for example 3 loads + on the grid, then you need to use time series that somehow can generate + 3 values at each step for load active values and 3 values at each step for load + reactive values. So the dimension of the :orange:`orange` variables is somehow + related to dimension of :green:`green` variables : you cannot use the + time series you want on the grid you want. + +Structural informations +~~~~~~~~~~~~~~~~~~~~~~~~ + +To define mathematically the MPD we need first to define some notations about the grid manipulated in +this episode. + +We suppose that the structure of the grid does not change during the episode, with: + +- :green:`n_line` being the number of "powerlines" (and transformers) which are elements that allow the + power flows to actually move from one place to another +- :green:`n_gen` being the number of generators, which are elements that produces the power +- :green:`n_load` being the number of consumers, which are elements that consume the power (typically a city or a + large industrial plant manufacturing) +- :green:`n_storage` being the number of storage units on the grid, which are elements that allow to + convert the power into a form of energy that can be stored (*eg* chemical) + +All these elements (side of powerlines, generators, loads and storage units) +are connected together at so called "substation". The grid counts :green:`n_sub` such substations. +We will call :green:`dim_topo := 2 \times n_line + n_gen + n_load + n_storage` the total number +of elements in the grid. + +.. note:: + This "substation" concept only means that if two elements does not belong to the same substations, they cannot + be directly connected at the same "node" of the graph. + + They can be connected in the same "connex component" of the graph (meaning that there are edges that + can connect them) but they cannot be part of the same "node" + +Each substation can be divided into :blue:`n_busbar_per_sub` (was only `2` in grid2op <= 1.9.8 and can be +any integer > 0 in grid2op version >= 1.9.9). + +This :blue:`n_busbar_per_sub` parameters tell the maximum number of independant nodes their can be in a given substation. +So to count the total maximum number of nodes in the grid, you can do +:math:`\text{n\_busbar\_per\_sub} \times \text{n\_sub}` + +When the grid is loaded, the backend also informs the environment about the :green:`***_to_subid` vectors +(*eg* :green:`gen_to_subid`) +which give, for each element to which substation they are connected. This is how the "constraint" of + +.. note:: + **Definition** + + With these notations, two elements are connected together if (and only if, that's a + definition after all): + + - they belong to the same substation + - they are connected to the same busbar + + In this case, we can also say that these two elements are connected to the same "bus". + + These "buses" are the "nodes" in "the" graph you thought about when looking at a powergrid. + +.. note:: + **Definition** ("disconnected bus"): A bus is said to be disconnected if there are no elements connected to it. + +.. note:: + **Definition** ("disconnected element"): An element (side of powerlines, generators, loads or storage units) + is said to be disconnected if it is not connected to anything. + +Extra references: ++++++++++++++++++ + +You can modify :blue:`n_busbar_per_sub` in the `grid2op.make` function. For example, +by default if you call `grid2op.make("l2rpn_case14_sandbox")` you will have :blue:`n_busbar_per_sub = 2` +but if you call `grid2op.make("l2rpn_case14_sandbox", n_busbar=3)` you will have +:blue:`n_busbar_per_sub = 3` see :ref:`substation-mod-el` for more information. + +:green:`n_line`, :green:`n_gen`, :green:`n_load`, :green:`n_storage` and :green:`n_sub` depends on the environment +you loaded when calling `grid2op.make`, for example calling `grid2op.make("l2rpn_case14_sandbox")` +will lead to environment +with :green:`n_line = 20`, :green:`n_gen = 6`, :green:`n_load = 11` and :green:`n_storage = 0`. + +Other informations +~~~~~~~~~~~~~~~~~~~~~~~~ + +When loading the environment, there are also some other static data that are loaded which includes: + +- :green:`min_storage_p` and :green:`max_storage_p`: the minimum power that can be injected by + each storage units (typically :green:`min_storage_p` :math:`< 0`). These are vectors + (of real numbers) of size :green:`n_storage` +- :green:`is_gen_renewable`: a vector of `True` / `False` indicating for each generator whether + it comes from new renewable (and intermittent) renewable energy sources (*eg* solar or wind) +- :green:`is_gen_controlable`: a vector of `True` / `False` indicating for each generator + whether it can be controlled by the agent to produce both more or less power + at any given step. This is usually the case for generator which uses + as primary energy coal, gaz, nuclear or water (hyrdo powerplant) +- :green:`min_ramp` and :green:`max_ramp`: are two vector giving the maximum amount + of power each generator can be adjusted to produce more / less. Typically, + :green:`min_ramp = max_ramp = 0` for non controlable generators. + +.. note:: + These elements are marked :green:`green` because they are loaded by the backend, but strictly speaking + they can be specified in other files than the one representing the powergrid. + +Action space +~~~~~~~~~~~~~ + +At time of writing, grid2op support different type of actions: + +- :blue:`change_line_status`: that will change the line status (if it is disconnected + this action will attempt to connect it). It leaves in :math:`\left\{0,1\right\}^{\text{n\_line}}` +- :blue:`set_line_status`: that will set the line status to a + particular state regardless of the previous state (+1 to attempt a force + reconnection on the powerline and -1 to attempt a force disconnection). + There is also a special case where the agent do not want to modify a given line and + it can then output "0" + It leaves in :math:`\left\{-1, 0, 1\right\}^{\text{n\_line}}` +- \* :blue:`change_bus`: that will, for each element of the grid change the busbars + to which it is connected (*eg* if it was connected on busbar 1 it will attempt to connect it on + busbar 2). This leaves in :math:`\left\{0,1\right\}^{\text{dim\_topo}}` +- :blue:`set_bus`: that will, for each element control on which busbars you want to assign it + to (1, 2, ..., :blue:`n_busbar_per_sub`). To which has been added 2 special cases -1 means "disconnect" this element + and 0 means "I don't want to affect" this element. This part of the action space then leaves + in :math:`\left\{-1, 0, 1, 2, ..., \text{n\_busbar\_per\_sub} \right\}^{\text{dim\_topo}}` +- :blue:`storage_p`: for each storage, the agent can chose the setpoint / target power for + each storage units. It leaves in + :math:`[\text{min\_storage\_p}, \text{max\_storage\_p}] \subset \mathbb{R}^{\text{n\_storage}}` +- :blue:`curtail`: corresponds to the action where the agent ask a generator (using renewable energy sources) + to produce less than what would be possible given the current weather. This type of action can + only be performed on renewable generators. It leaves in :math:`[0, 1]^{\text{n\_gen}}` + (to avoid getting the notations even more complex, we won't define exactly the space of this + action. Indeed, writing :math:`[0, 1]^{\text{n\_gen}}` is not entirely true as a non renewable generator + will not be affected by this type of action) +- :blue:`redisp`: corresponds to the action where the agent is able to modify (to increase or decrease) + the generator output values (asking at the some producers to produce more and at some + to produce less). It leaves in :math:`[\text{min\_ramp}, \text{max\_ramp}] \subset \mathbb{R}^{\text{n\_gen}}` + (remember that for non controlable generators, by definition we suppose that :green:`min_ramp = max_ramp = 0`) + +.. note:: + The :blue:`change_bus` is only available in environment where :blue:`n_busbar_per_sub = 2` + otherwise this would not make sense. The action space does not include this + type of actions if :blue:`n_busbar_per_sub != 2` + +You might have noticed that every type of actions is written in :blue:`blue`. This is because +the action space can be defined at the creation of the environment, by specifying in +the call to `grid2op.make` the `action_class` to be used. + +Let's call :math:`1_{\text{change\_line\_status}}` either :math:`\left\{0,1\right\}^{\text{n\_line}}` +(corresponding to the definition of the :blue:`change_line_status` briefly described above) if the +:blue:`change_line_status` has been selected by the user (for the entire scenario) or the +:math:`\emptyset` otherwise (and we do similarly for all other type of actions of course: for example: +:math:`1_{redisp} \in \left\{[\text{min\_ramp}, \text{max\_ramp}], \emptyset\right\}`) + +Formally then, the action space can then be defined as: + +.. math:: + :nowrap: + + \begin{align*} + \mathcal{A}\text{space\_type} =&\left\{\text{change\_line\_status}, \text{set\_line\_status}, \right. \\ + &~\left.\text{change\_bus}, \text{set\_bus}, \right.\\ + &~\left.\text{storage\_p}, \text{curtail}, \text{redisp} \right\} \\ + \mathcal{A} =&\Pi_{\text{a\_type} \in \mathcal{A}\text{space\_type} } 1_{\text{a\_type}}\\ + \end{align*} + +.. note:: + In the grid2op documentation, the words "topological modification" are often used. + When that is the case, unless told otherwise it means + :blue:`set_bus` or :blue:`change_bus` type of actions. + + +Extra references: ++++++++++++++++++ + +Informatically, the :math:`1_{\text{change\_line\_status}}` can be define at the +call to `grid2op.make` when the environment is created (and cannot be changed afterwards). + +For example, if the user build the environment like this : + +.. code-block:: python + + import grid2op + from grid2op.Action import PlayableAction + env_name = ... # whatever, eg "l2rpn_case14_sandbox" + env = grid2op.make(env_name, action_class=PlayableAction) + +Then all type of actions are selected and : + +.. math:: + :nowrap: + + \begin{align*} + \mathcal{A} =& \left\{0,1\right\}^{\text{n\_line}}~ \times & \text{change\_line\_status} \\ + & \left\{-1, 0, 1\right\}^{\text{n\_line}}~ \times & \text{set\_line\_status} \\ + & \left\{0,1\right\}^{\text{dim\_topo}}~ \times & \text{change\_bus} \\ + & \left\{-1, 0, 1, 2, ..., \text{n\_busbar\_per\_sub} \right\}^{\text{dim\_topo}}~ \times & \text{set\_bus} \\ + & ~[\text{min\_storage\_p}, \text{max\_storage\_p}]~ \times & \text{storage\_p} \\ + & ~[0, 1]^{\text{n\_gen}} \times & \text{curtail} \\ + & ~[\text{min\_ramp}, \text{max\_ramp}] & \text{redisp} + \end{align*} + +You can also build the same environment like this: + +.. code-block:: python + + import grid2op + from grid2op.Action import TopologySetAction + same_env_name = ... # whatever, eg "l2rpn_case14_sandbox" + env = grid2op.make(same_env_name, action_class=TopologySetAction) + +Which will lead the following action space, because the user ask to +use only "topological actions" (including line status) with only the +"set" way of modifying them. + +.. math:: + :nowrap: + + \begin{align*} + \mathcal{A} =& \left\{-1, 0, 1\right\}^{\text{n\_line}}~ \times & \text{set\_line\_status} \\ + & \left\{-1, 0, 1, 2, ..., \text{n\_busbar\_per\_sub} \right\}^{\text{dim\_topo}}~ & \text{set\_bus} \\ + \end{align*} + +The page :ref:`action-module` of the documentation provides you with all types of +actions you you can use in grid2op. + +.. note:: + If you use a compatibility with the popular gymnasium (previously gym) + you can also specify the action space with the "`attr_to_keep`" + key-word argument. + +.. _mdp-state-space-def: + +State space +~~~~~~~~~~~~~ + +By default in grid2op, the state space shown to the agent (the so called +"observation"). In this part of the documentation, we will described something +slightly different which is the "state space" of the MDP. + +The main difference is that this "state space" will include future data about the +environment (*eg* the :math:`\mathcal{X}` matrix). You can refer to +section :ref:`pomdp` or :ref:`non-pomdp` of this page of the documentation. + +.. note:: + We found it easier to show the MDP without the introduction of the + "observation kernel", so keep in mind that this paragraph is not + representative of the observation in grid2op but is "purely + theoretical". + +The state space is defined by different type of attributes and we will not list +them all here (you can find a detailed list of everything available to the +agent in the :ref:`observation_module` page of the documentation.) The +"state space" is then made of: + +- some part of the outcome of the solver: + :math:`S_{\text{grid}} \subset \mathcal{S}_{\text{im}}^{(\text{out})}`, this + includes but is not limited to the loads active values `load_p`_, + loads reactive values `load_q`_, voltage magnitude + at each loads `load_v`_, the same kind of attributes but for generators + `gen_p`_, `gen_q`_, `gen_v`_, `gen_theta`_ and also for powerlines + `p_or`_, `q_or`_, `v_or`_, `a_or`_, `theta_or`_, `p_ex`_, `q_ex`_, `v_ex`_, + `a_ex`_, `theta_ex`_, `rho`_ etc. +- some attributes related to "redispatching" (which is a type of actions) that is + computed by the environment (see :ref:`mdp-transition-kernel-def` for more information) + which includes `target_dispatch`_ and `actual_dispatch`_ or the curtailment + `gen_p_before_curtail`_, `curtailment_mw`_, `curtailment`_ or `curtailment_limit`_ +- some attributes related to "storage units", for example `storage_charge`_ , + `storage_power_target`_, `storage_power`_ or `storage_theta`_ +- some related to "date" and "time", `year`_, `month`_, `day`_, `hour_of_day`_, + `minute_of_hour`_, `day_of_week`_, `current_step`_, `max_step`_, `delta_time`_ +- finally some related to the :blue:`rules of the game` like + `timestep_overflow`_, `time_before_cooldown_line`_ or `time_before_cooldown_sub`_ + +And, to make it "Markovian" we also need to include : + +- the (constant) values of :math:`\mathcal{S}_{\text{im}}^{(\text{in})}` that + are not "part of" :math:`\mathcal{X}` (more information about that in + the paragraph ":ref:`mdp-call-simulator-step`" of this documentation). + This might include some physical + parameters of some elements of the grid (like transformers or powerlines) or + some other parameters of the solver controlling either the equations to be + solved or the solver to use etc. \* +- the complete matrix :math:`\mathcal{X}` which include the exact knowledge of + past, present **and future** loads and generation for the entire scenario (which + is not possible in practice). The matrix itself is constant. +- the index representing at which "step" of the matrix :math:`\mathcal{X}` the + current data are being used by the environment. + +.. note:: + \* grid2op is build to be "simulator agnostic" so all this part of the "state space" + is not easily accessible through the grid2op API. To access (or to modify) them + you need to be aware of the implementation of the :class:`grid2op.Backend.Backend` + you are using. + +.. note:: + In this modeling, by design, the agent sees everything that will happen in the + future, without uncertainties. To make a parrallel with a "maze" environment, + the agent would see the full maze and its position at each step. + + This is of course not fully representative of the daily powergrid operations, + where the operators cannot see exactly the future. To make this modeling + closer to the reality, you can refer to the paragphs :ref:`pomdp` and :ref:`non-pomdp` + below. + +.. _mdp-transition-kernel-def: + +Transition Kernel +~~~~~~~~~~~~~~~~~~~ + +In this subsection we will describe the so called transition kernel, this is the function that given a +state :math:`s` and an action :math:`a` gives a probability distribution over all possible next state +:math:`s' \in \mathcal{S}`. + +In this subsection, we chose to model this transition kernel as a deterministic +function (which is equivalent to saying that the probability distribution overs :math:`\mathcal{S}` is +a Dirac distribution). + +.. note:: + The removal of the :math:`\mathcal{X}` matrix in the "observation space" see section :ref:`pomdp` or the + rewriting of the MDP to say in the "fully observable setting" (see section :ref:`non-pomdp`) or the + introduction of the "opponent" described in section :ref:`mdp-opponent` are all things that "makes" this + "transition kernel" probabilistic. We chose the simplicity in presenting it in a fully deterministic + fashion. + +So let's write what the next state is given the current state :math:`s \in \mathcal{S}` and the action of +the agent :math:`a \in \mathcal{A}`. To do that we split the computation in different steps explained bellow. + +.. note:: + To be exhaustive, if the actual state is :math:`s = s_{\emptyset}` then the :math:`s' = s_{\emptyset}` is + returned regardless of the action and the steps described below are skipped. + +If the end of the episode is reached then :math:`s' = s_{\emptyset}` is returned. + +Step 1: legal vs illegal ++++++++++++++++++++++++++ + +The first step is to check if the action is :blue:`legal` or not. This depends on the :blue:`rules` (see the +dedicated page :ref:`rule-module` of the documentation) and the :blue:`parameters` (more information at the page +:ref:`parameters-module` of the documentation). There are basically two cases: + +#. the action :math:`a` is legal: then proceed to next step +#. the action :math:`a` is not, then replace the action by `do nothing`, an action that does not + affect anything and proceed to next step + +.. _mdp-read-x-values: + +Step 2: load next environment values ++++++++++++++++++++++++++++++++++++++ + +This is also rather straightforward, the current index is updated (+1 is added) and this +new index is used to find the "optimal" (from a market or a central authority perspective) +value each producer produce to satisfy the demand mof each consumers (in this case large cities or +companies). These informations are stored in the :math:`\mathcal{X}` matrix. + +.. _mdp-redispatching-step: + +Step 3: Compute the generators setpoints and handle storage units +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ + +The next step of the environment is to handle the "continuous" part of the action (*eg* "storage_p", +"curtail" or "redisp") and to make sure a suitable setpoint can be reached for each generators (you +can refer to the pages :ref:`storage-mod-el` and :ref:`generator-mod-el` of this documentation +for more information). + +There are two alternatives: + +#. either the physical constraints cannot be met (there exist no feasible solutions + for at least one generator), and in this case the next state is the + terminal state :math:`s_{\emptyset}` (ignore all the steps bellow) +#. or they can be met. In this case the "target generator values" is computed as well + as the "target storage unit values" + +.. note:: + There is a parameters called :blue:`LIMIT_INFEASIBLE_CURTAILMENT_STORAGE_ACTION` that will + try to avoid, as best as possible to fall into infeasibile solution. It does so by limiting + the amount of power that is curtailed or injected in the grid from the storage units: it + modifies the actions :math:`a`. + +.. _mdp-call-simulator-step: + +Step 4: Call the simulator ++++++++++++++++++++++++++++++++ + +At this stage then (assuming the physical constraints can be met), the setpoint for the following variables +is known: + +- the status of the lines is deduced from the "change_line_status" and "set_line_status" and their + status in :math:`s` (the current state). If there are maintenance (or attacks, see section + :ref:`mdp-opponent`) they can also disconnect powerlines. +- the busbar to which each elements is connected is also decuced from the "change_bus" and + "set_bus" part of the action +- the consumption active and reactive values have been computed from the :math:`\mathcal{X}` + values at previous step +- the generator active values have just been computed after taking into account the redispatching, + curtailement and storage (at this step) +- the voltage setpoint for each generators is either read from :math:`\mathcal{X}` or + deduced from the above data by the "voltage controler" (more information on :ref:`voltage-controler-module`) + +All this should be part of the input solver data :math:`\mathcal{S}_{\text{im}}^{(\text{in})}`. If not, then the +solver cannot be used unfortunately... + +With that (and the other data used by the solver and included in the space, see paragraph +:ref:`mdp-state-space-def` of this documentation), the necessary data is shaped (by the Backend) into +a valid :math:`s_{\text{im}}^{(\text{in})} \in \mathcal{S}_{\text{im}}^{(\text{in})}`. + +The solver is then called and there are 2 alternatives (again): + +#. either the solver cannot find a feasible solution (it "diverges"), and in this case the next state is the + terminal state :math:`s_{\emptyset}` (ignore all the steps bellow) +#. or a physical solution is found and the process carries out in the next steps + +.. _mdp-protection-emulation-step: + +Step 5: Emulation of the "protections" +++++++++++++++++++++++++++++++++++++++++++ + +At this stage an object :math:`s_{\text{im}}^{(\text{out})} \in \mathcal{S}_{\text{im}}^{(\text{out})}` +has been computed by the solver. + +The first step performed by grid2op is to look at the flows (in Amps) on the powerlines (these data +are part of :math:`s_{\text{im}}^{(\text{out})}`) and to check whether they meet some constraints +defined in the :blue:`parameters` (mainly if for some powerline the flow is too high, or if it has been +too high for too long, see :blue:`HARD_OVERFLOW_THRESHOLD`, :blue:`NB_TIMESTEP_OVERFLOW_ALLOWED` and +:blue:`NO_OVERFLOW_DISCONNECTION`). If some powerlines are disconnected at this step, then the +"setpoint" send to the backend at the previous step is modified and it goes back +to :ref:`mdp-call-simulator-step`. + +.. note:: + The simulator can already handle a real simulation of these "protections". This "outer loop" + is because some simulators does not do it. + +.. note:: + For the purist, this "outer loop" necessarily terminates. It is trigger when at least one + powerline needs to be disconnected. And there are :green:`n_line` (finite) powerlines. + +Step 6: Reading back the "grid dependant" attributes +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ + +At this stage an object :math:`s_{\text{im}}^{(\text{out})} \in \mathcal{S}_{\text{im}}^{(\text{out})}` +has been computed by the solver and all the "rules" / "parameters" regarding powerlines +are met. + +As discussed in the section about "state space" (see :ref:`mdp-state-space-def` for more information), +the next state space :math:`s'` include some part of the outcome of the solver. These data +are then read from the :math:`s_{\text{im}}^{(\text{out})}`, which +includes but is not limited to the loads active values `load_p`_, +loads reactive values `load_q`_, voltage magnitude +at each loads `load_v`_, the same kind of attributes but for generators +`gen_p`_, `gen_q`_, `gen_v`_, `gen_theta`_ and also for powerlines +`p_or`_, `q_or`_, `v_or`_, `a_or`_, `theta_or`_, `p_ex`_, `q_ex`_, `v_ex`_, +`a_ex`_, `theta_ex`_, `rho`_ etc. + + +Step 7: update the other attributes of the state space ++++++++++++++++++++++++++++++++++++++++++++++++++++++++ + +Finally, the environment takes care of updating all the other "part" +of the state space, which are: + +- attributes related to "redispatching" are updated at in paragraph :ref:`mdp-redispatching-step` +- and so are attributes related to storage units +- the information about the date and time are loaded from the :math:`\mathcal{X}` matrix. + +As for the attributes related to the rules of the game, they are updated in the following way: + +- `timestep_overflow`_ is set to 0 for all powerlines not in overflow and increased by 1 for all the other +- `time_before_cooldown_line`_ is reduced by 1 for all line that has not been impacted by the action :math:`a` + otherwise set to :blue:`param.NB_TIMESTEP_COOLDOWN_LINE` +- `time_before_cooldown_sub`_ is reduced by 1 for all substations that has not been impacted by the action :math:`a` + otherwise set to :blue:`param.NB_TIMESTEP_COOLDOWN_SUB` + +The new state :math:`s'` is then passed to the agent. + +.. note:: + We remind that this process might have terminated before reaching the last step described above, for example + at :ref:`mdp-redispatching-step` or at :ref:`mdp-call-simulator-step` or during the + emulation of the protections described at :ref:`mdp-protection-emulation-step` + +Reward Kernel +~~~~~~~~~~~~~~~~~~~ + +And to finish this (rather long) description of grid2op's MDP we need to mention the +"reward kernel". + +This "kernel" computes the reward associated to taking the action :math:`a` in step +:math:`s` that lead to step :math:`s'`. In most cases, the +reward in grid2op is a deterministic function and depends only on the grid state. + +In grid2op, every environment comes with a pre-defined :blue:`reward function` that +can be fully customized by the user when the environment is created or +even afterwards (but is still constant during an entire episode of course). + +For more information, you might want to have a look at the :ref:`reward-module` page +of this documentation. + +Extensions +----------- + +In this last section of this page of the documentation, we dive more onto some aspect of the grid2op MDP. + +.. note:: + TODO: This part of the section is still an ongoing work. + + Let us know if you want to contribute ! + + +.. _pomdp: + +Partial Observatibility +~~~~~~~~~~~~~~~~~~~~~~~~~~ + +This is the case in most grid2op environments: only some part of the environment +state at time `t` :math:`s_t` are +given to the agent in the observation at time `t` :math:`o_t`. + +Mathematically this can be modeled with the introduction of an "observation space" and an +"observation kernel". This kernel will only expose part of the "state space" to the agent and +(in grid2op) is a deterministic function that depends on the environment state :math:`s'`. + +More specifically, in most grid2op environment (by default at least), none of the +physical parameters of the solvers are provided. Also, to represent better +the daily operation in power systems, only the `t` th row of the matrix :math:`\mathcal{X}_t` +is given in the observation :math:`o_t`. The components :math:`\mathcal{X}_{t', i}` +(for :math:`\forall t' > t`) are not given. The observation kernel in grid2op will +mask out some part of the "environment state" to the agent. + +.. _non-pomdp: + +Or not partial observatibility ? +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +If we consider that the agent is aware of the simulator used and all it's "constant" (see +paragraph :ref:`mdp-state-space-def`) part of :math:`\mathcal{S}_{\text{im}}^{(\text{in})}` +(which are part of the simulator that are not affected by the actions of +the agent nor by environment) then we can model the grid2op MDP without the need +to use an observation kernel: it can be a regular MDP. + +To "remove" the need of partial observatibility, without the need to suppose that the +agent sees all the future we can adapt slightly the modeling which allows us to +remove completely the :math:`\mathcal{X}` matrix : + +- the observation space / state space (which are equal in this setting) are the same as the + one used in :ref:`pomdp` +- the transition kernel is now stochastic. Indeed, the "next" value of the loads and generators + are, in this modeling not read from a :math:`\mathcal{X}` matrix but sampled from a given + distribution which replaces the step :ref:`mdp-read-x-values` of subsection + :ref:`mdp-transition-kernel-def`. And once the values of these variables are sampled, + the rest of the steps described there are unchanged. + +.. note:: + The above holds as long as there exist a way to sample new values for gen_p, load_p, gen_v and + load_q that is markovian. We suppose it exists here and will not write it down. + +.. note:: + Sampling from these distribution can be quite challenging and will not be covered here. + + One of the challenging part is that the sampled generations need to meet the demand (and + the losses) as well as all the constraints on the generators (p_min, p_max and ramps) + +.. _mdp-opponent: + +Adversarial attacks +~~~~~~~~~~~~~~~~~~~~~~~~~~ + +TODO: explain the model of the environment + +Forecast and simulation on future states +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +TODO : explain the model the forecast and the fact that the "observation" also +includes a model of the world that can be different from the grid of the environment + +Simulator dynamics can be more complex +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +TODO, Backend does not need to "exactly map the simulator" there are +some examples below: + +Hide elements from the grid2op environment +++++++++++++++++++++++++++++++++++++++++++ + +TODO only a part of the grid would be "exposed" in the +grid2op environment. + + +Contain elements not modeled by grid2op +++++++++++++++++++++++++++++++++++++++++++ + +TODO: speak about HVDC or "pq" generators, or 3 winding transformers + +Contain embeded controls +++++++++++++++++++++++++++++++++++++++++++ + +TODO for example automatic setpoint for HVDC or limit on Q for generators + +Time domain simulation ++++++++++++++++++++++++ + +TODO: we can plug in simulator that solves more +accurate description of the grid and only "subsample" +(*eg* at a frequency of every 5 mins) provide grid2op +with some information. + +Handle the topology differently +++++++++++++++++++++++++++++++++++ + +Backend can operate switches, only requirement from grid2op is to map the topology +to switches. + +Some constraints +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +TODO + +Operator attention: alarm and alter +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +TODO + +.. include:: final.rst diff --git a/docs/model_based.rst b/docs/model_based.rst index 54f4c6f6e..3645fa6e9 100644 --- a/docs/model_based.rst +++ b/docs/model_based.rst @@ -3,6 +3,15 @@ Model Based / Planning methods ==================================== + +This page is organized as follow: + +.. contents:: Table of Contents + :depth: 3 + +Objectives +---------------- + .. warning:: This page is in progress. We welcome any contribution :-) diff --git a/docs/modeled_elements.rst b/docs/modeled_elements.rst index 634548455..9dc4509d3 100644 --- a/docs/modeled_elements.rst +++ b/docs/modeled_elements.rst @@ -1034,7 +1034,8 @@ Substations Description ~~~~~~~~~~~~~~~~~~ -A "substation" is a place where "elements" (side of a powerline, a load, a generator or +A "substation" is a place (that exists, you can touch it) +where "elements" (side of a powerline, a load, a generator or a storage unit) belonging to the powergrid are connected all together. Substations are connected to other substation with powerlines (this is why powerline have two "sides": one for @@ -1042,11 +1043,39 @@ each substation they are connecting). In most powergrid around the world, substations are made of multiple "busbars". In grid2op we supposes that every "elements" connected to a substation can be connected to every busbars in the substation. This is mainly -done for simplicity, for real powergrid it might not be the case. We also, for simplicity, assume that -each substations counts exactly 2 distincts busbars. +done for simplicity, for real powergrid it might not be the case. -At the initial step, for all environment available at the time of writing (february 2021) every objects -were connected to the busbar 1 of their substation. This is not a requirement of grid2op, but it was the case +In earlier grid2op versions, we also assumed that, for simplicity, +each substations counts exactly 2 distincts busbars. Starting from grid2op 1.9.9, it is possible +when you create an environment, to specify how many busbars are available in each substation. You can +customize it with: + +.. code-block:: python + + import grid2op + env_name = "l2rpn_case14_sandbox" + + env_2_busbars = grid2op.make(env_name) # default + env_2_busbars_bis = grid2op.make(env_name, n_busbar=2) # same as above + + # one busbar + env_1_busbar = grid2op.make(env_name, n_busbar=1) + #NB: topological action on substation (set_bus, change_bus) are not possible in this case ! + + # 3 busbars + env_3_busbars = grid2op.make(env_name, n_busbar=3) + #NB: "change_bus" type of actions are not possible (it would be ambiguous - non unique- + # on which busbar you want to change them) + + # 10 busbars + env_10_busbars = grid2op.make(env_name, n_busbar=10) + #NB: "change_bus" type of actions are not possible (it would be ambiguous - non unique- + # on which busbar you want to change them) + + +At the initial step (right after `env.reset()`), for all environment available +at the time of writing (february 2021) every objects were connected to the busbar 1 +of their substation. This is not a requirement of grid2op, but it was the case for every environments created. .. _topology-pb-explained: diff --git a/docs/observation.rst b/docs/observation.rst index 86bc3baba..97a881108 100644 --- a/docs/observation.rst +++ b/docs/observation.rst @@ -1,70 +1,7 @@ .. currentmodule:: grid2op.Observation -.. _n_gen: ./space.html#grid2op.Space.GridObjects.n_gen -.. _n_load: ./space.html#grid2op.Space.GridObjects.n_load -.. _n_line: ./space.html#grid2op.Space.GridObjects.n_line -.. _n_sub: ./space.html#grid2op.Space.GridObjects.n_sub -.. _n_storage: ./space.html#grid2op.Space.GridObjects.n_storage -.. _dim_topo: ./space.html#grid2op.Space.GridObjects.dim_topo -.. _dim_alarms: ./space.html#grid2op.Space.GridObjects.dim_alarms -.. _dim_alerts: ./space.html#grid2op.Space.GridObjects.dim_alerts -.. _year: ./observation.html#grid2op.Observation.BaseObservation.year -.. _month: ./observation.html#grid2op.Observation.BaseObservation.month -.. _day: ./observation.html#grid2op.Observation.BaseObservation.day -.. _hour_of_day: ./observation.html#grid2op.Observation.BaseObservation.hour_of_day -.. _minute_of_hour: ./observation.html#grid2op.Observation.BaseObservation.minute_of_hour -.. _day_of_week: ./observation.html#grid2op.Observation.BaseObservation.day_of_week -.. _gen_p: ./observation.html#grid2op.Observation.BaseObservation.gen_p -.. _gen_q: ./observation.html#grid2op.Observation.BaseObservation.gen_q -.. _gen_v: ./observation.html#grid2op.Observation.BaseObservation.gen_v -.. _load_p: ./observation.html#grid2op.Observation.BaseObservation.load_p -.. _load_q: ./observation.html#grid2op.Observation.BaseObservation.load_q -.. _load_v: ./observation.html#grid2op.Observation.BaseObservation.load_v -.. _p_or: ./observation.html#grid2op.Observation.BaseObservation.p_or -.. _q_or: ./observation.html#grid2op.Observation.BaseObservation.q_or -.. _v_or: ./observation.html#grid2op.Observation.BaseObservation.v_or -.. _a_or: ./observation.html#grid2op.Observation.BaseObservation.a_or -.. _p_ex: ./observation.html#grid2op.Observation.BaseObservation.p_ex -.. _q_ex: ./observation.html#grid2op.Observation.BaseObservation.q_ex -.. _v_ex: ./observation.html#grid2op.Observation.BaseObservation.v_ex -.. _a_ex: ./observation.html#grid2op.Observation.BaseObservation.a_ex -.. _rho: ./observation.html#grid2op.Observation.BaseObservation.rho -.. _topo_vect: ./observation.html#grid2op.Observation.BaseObservation.topo_vect -.. _line_status: ./observation.html#grid2op.Observation.BaseObservation.line_status -.. _timestep_overflow: ./observation.html#grid2op.Observation.BaseObservation.timestep_overflow -.. _time_before_cooldown_line: ./observation.html#grid2op.Observation.BaseObservation.time_before_cooldown_line -.. _time_before_cooldown_sub: ./observation.html#grid2op.Observation.BaseObservation.time_before_cooldown_sub -.. _time_next_maintenance: ./observation.html#grid2op.Observation.BaseObservation.time_next_maintenance -.. _duration_next_maintenance: ./observation.html#grid2op.Observation.BaseObservation.duration_next_maintenance -.. _target_dispatch: ./observation.html#grid2op.Observation.BaseObservation.target_dispatch -.. _actual_dispatch: ./observation.html#grid2op.Observation.BaseObservation.actual_dispatch -.. _storage_charge: ./observation.html#grid2op.Observation.BaseObservation.storage_charge -.. _storage_power_target: ./observation.html#grid2op.Observation.BaseObservation.storage_power_target -.. _storage_power: ./observation.html#grid2op.Observation.BaseObservation.storage_power -.. _gen_p_before_curtail: ./observation.html#grid2op.Observation.BaseObservation.gen_p_before_curtail -.. _curtailment: ./observation.html#grid2op.Observation.BaseObservation.curtailment -.. _curtailment_limit: ./observation.html#grid2op.Observation.BaseObservation.curtailment_limit -.. _is_alarm_illegal: ./observation.html#grid2op.Observation.BaseObservation.is_alarm_illegal -.. _time_since_last_alarm: ./observation.html#grid2op.Observation.BaseObservation.time_since_last_alarm -.. _last_alarm: ./observation.html#grid2op.Observation.BaseObservation.last_alarm -.. _attention_budget: ./observation.html#grid2op.Observation.BaseObservation.attention_budget -.. _max_step: ./observation.html#grid2op.Observation.BaseObservation.max_step -.. _current_step: ./observation.html#grid2op.Observation.BaseObservation.current_step -.. _delta_time: ./observation.html#grid2op.Observation.BaseObservation.delta_time -.. _gen_margin_up: ./observation.html#grid2op.Observation.BaseObservation.gen_margin_up -.. _gen_margin_down: ./observation.html#grid2op.Observation.BaseObservation.gen_margin_down -.. _curtailment_mw: ./observation.html#grid2op.Observation.BaseObservation.curtailment_mw -.. _theta_or: ./observation.html#grid2op.Observation.BaseObservation.theta_or -.. _theta_ex: ./observation.html#grid2op.Observation.BaseObservation.theta_ex -.. _gen_theta: ./observation.html#grid2op.Observation.BaseObservation.gen_theta -.. _load_theta: ./observation.html#grid2op.Observation.BaseObservation.load_theta -.. _active_alert: ./observation.html#grid2op.Observation.BaseObservation.active_alert -.. _time_since_last_alert: ./observation.html#grid2op.Observation.BaseObservation.time_since_last_alert -.. _alert_duration: ./observation.html#grid2op.Observation.BaseObservation.alert_duration -.. _total_number_of_alert: ./observation.html#grid2op.Observation.BaseObservation.total_number_of_alert -.. _time_since_last_attack: ./observation.html#grid2op.Observation.BaseObservation.time_since_last_attack -.. _was_alert_used_after_attack: ./observation.html#grid2op.Observation.BaseObservation.was_alert_used_after_attack -.. _attack_under_alert: ./observation.html#grid2op.Observation.BaseObservation.attack_under_alert +.. include:: special.rst +.. include the observation attributes .. _observation_module: diff --git a/docs/parameters.rst b/docs/parameters.rst index f89ccc78e..727a422e5 100644 --- a/docs/parameters.rst +++ b/docs/parameters.rst @@ -1,6 +1,8 @@ +.. _parameters-module: + Parameters =================================== -The challenge "learning to run a power network" offers different _parameters to be customized, or to learn an +The challenge "learning to run a power network" offers different parameters to be customized, or to learn an :class:`grid2op.Agent` that will perform better for example. This class is an attempt to group them all inside one single structure. @@ -10,6 +12,7 @@ come soon. Example -------- + If you want to change the parameters it is better to do it at the creation of the environment. This can be done with: diff --git a/docs/reward.rst b/docs/reward.rst index 555988adf..049962952 100644 --- a/docs/reward.rst +++ b/docs/reward.rst @@ -1,5 +1,7 @@ .. currentmodule:: grid2op.Reward +.. _reward-module: + Reward =================================== @@ -20,6 +22,225 @@ some phenomenon by simulating the effect of some :class:`grid2op.Action` using Doing so only requires to derive the :class:`BaseReward`, and most notably the three abstract methods :func:`BaseReward.__init__`, :func:`BaseReward.initialize` and :func:`BaseReward.__call__` +Customization of the reward +----------------------------- + +In grid2op you can customize the reward function / reward kernel used by your agent. By default, when you create an +environment a reward has been specified for you by the creator of the environment and you have nothing to do: + +.. code-block:: python + + import grid2op + env_name = "l2rpn_case14_sandbox" + + env = grid2op.make(env_name) + + obs = env.reset() + an_action = env.action_space() + obs, reward_value, done, info = env.step(an_action) + +The value of the reward function above is computed by a default function that depends on +the environment you are using. For the example above, the "l2rpn_case14_sandbox" environment is +using the :class:`RedispReward`. + +Using a reward function available in grid2op +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +If you want to customize your environment by adapting the reward and use a reward available in grid2op +it is rather simple, you need to specify it in the `make` command: + + +.. code-block:: python + + import grid2op + from grid2op.Reward import EpisodeDurationReward + env_name = "l2rpn_case14_sandbox" + + env = grid2op.make(env_name, reward_class=EpisodeDurationReward) + + obs = env.reset() + an_action = env.action_space() + obs, reward_value, done, info = env.step(an_action) + +In this example the `reward_value` is computed using the formula defined in the :class:`EpisodeDurationReward`. + +.. note:: + There is no error in the syntax. You need to provide the class and not an object of the class + (see next paragraph for more information about that). + +At time of writing the available reward functions is : + +- :class:`AlarmReward` +- :class:`AlertReward` +- :class:`BridgeReward` +- :class:`CloseToOverflowReward` +- :class:`ConstantReward` +- :class:`DistanceReward` +- :class:`EconomicReward` +- :class:`EpisodeDurationReward` +- :class:`FlatReward` +- :class:`GameplayReward` +- :class:`IncreasingFlatReward` +- :class:`L2RPNReward` +- :class:`LinesCapacityReward` +- :class:`LinesReconnectedReward` +- :class:`N1Reward` +- :class:`RedispReward` + +In the provided reward you have also some convenience functions to combine different reward. These are: + +- :class:`CombinedReward` +- :class:`CombinedScaledReward` + +Basically these two classes allows you to combine (sum) different reward in a single one. + +Passing an instance instead of a class +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +On some occasion, it might be easier to work with instance of classes (object) +rather than to work with classes (especially if you want to customize the implementation used). +You can do this without any issue: + + +.. code-block:: python + + import grid2op + from grid2op.Reward import N1Reward + env_name = "l2rpn_case14_sandbox" + + n1_l1_reward = N1Reward(l_id=1) # this is an object and not a class. + env = grid2op.make(env_name, reward_class=n1_l1_reward) + + obs = env.reset() + an_action = env.action_space() + obs, reward_value, done, info = env.step(an_action) + +In this example `reward_value` is computed as being the maximum flow on all the powerlines after +the disconnection of powerline `1` (because we specified `l_id=1` at creation). If we +want to know the maximum flows after disconnection of powerline `5` you can call: + +.. code-block:: python + + import grid2op + from grid2op.Reward import N1Reward + env_name = "l2rpn_case14_sandbox" + + n1_l5_reward = N1Reward(l_id=5) # this is an object and not a class. + env = grid2op.make(env_name, reward_class=n1_l5_reward) + +Customizing the reward for the "simulate" +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +In grid2op, you have the possibility to `simulate` the impact of an action +on some future steps with the use of `obs.simulate(...)` (see :func:`grid2op.Observation.BaseObservation.simulate`) +or `obs.get_forecast_env()` (see :func:`grid2op.Observation.BaseObservation.get_forecast_env`). + +In these methods you have some computations of rewards. Grid2op lets you allow to customize how these rewards +are computed. You can change it in multiple fashion: + +.. code-block:: python + + import grid2op + from grid2op.Reward import EpisodeDurationReward + env_name = "l2rpn_case14_sandbox" + + env = grid2op.make(env_name, reward_class=EpisodeDurationReward) + obs = env.reset() + + an_action = env.action_space() + sim_obs, sim_reward, sim_d, sim_i = obs.simulate(an_action) + +By default `sim_reward` is comupted with the same function as the environment, in this +example :class:`EpisodeDurationReward`. + +If for some reason you want to customize the formula used to compute `sim_reward` and cannot (or +does not want to) modify the reward of the environment you can: + +.. code-block:: python + + import grid2op + from grid2op.Reward import EpisodeDurationReward + env_name = "l2rpn_case14_sandbox" + + env = grid2op.make(env_name) + obs = env.reset() + + env.observation_space.change_reward(EpisodeDurationReward) + an_action = env.action_space() + + sim_obs, sim_reward, sim_d, sim_i = obs.simulate(an_action) + next_obs, reward_value, done, info = env.step(an_action) + +In this example, `sim_reward` is computed using the `EpisodeDurationReward` (on forecast data) +and `reward_value` is computed using the default reward of "l2rpn_case14_sandbox" on the +"real" time serie data. + +Creating a new reward +~~~~~~~~~~~~~~~~~~~~~~ + +If you don't find any suitable reward function in grid2op (or in other package) you might +want to implement one yourself. + +To that end, you need to implement a class that derives from :class:`BaseReward`, like this: + +.. code-block:: python + + import grid2op + from grid2op.Reward import BaseReward + from grid2op.Action import BaseAction + from grid2op.Environment import BaseEnv + + + class MyCustomReward(BaseReward): + def __init__(self, whatever, you, want, logger=None): + self.whatever = blablabla + # some code needed + ... + super().__init__(logger) + + def __call__(self, + action: BaseAction, + env: BaseEnv, + has_error: bool, + is_done: bool, + is_illegal: bool, + is_ambiguous: bool) -> float: + # only method really required. + # called at each step to compute the reward. + # this is where you need to code the "formula" of your reward + ... + + def initialize(self, env: BaseEnv): + # optional + # called once, the first time the reward is used + pass + + def reset(self, env: BaseEnv): + # optional + # called by the environment each time it is "reset" + pass + + def close(self): + # optional called once when the environment is deleted + pass + + +And then you can use your (custom) reward like any other: + +.. code-block:: python + + import grid2op + from the_above_script import MyCustomReward + env_name = "l2rpn_case14_sandbox" + + custom_reward = MyCustomReward(whatever=1, you=2, want=42) + env = grid2op.make(env_name, reward_class=custom_reward) + obs = env.reset() + an_action = env.action_space() + obs, reward_value, done, info = env.step(an_action) + +And now `reward_value` is computed using the formula you defined in `__call__` + Training with multiple rewards ------------------------------- In the standard reinforcement learning framework the reward is unique. In grid2op, we didn't want to modify that. @@ -52,6 +273,13 @@ key word arguments. The only restriction is that the key "__score" will be use b score the agent. Any attempt to modify it will be erased by the score function used by the organizers without any warning. +.. _reward-module-reset-focus: + +What happens in the "reset" +------------------------------ + +TODO + Detailed Documentation by class -------------------------------- .. automodule:: grid2op.Reward @@ -59,4 +287,4 @@ Detailed Documentation by class :special-members: :autosummary: -.. include:: final.rst \ No newline at end of file +.. include:: final.rst diff --git a/docs/rules.rst b/docs/rules.rst index 24e7c087e..40ef5ac40 100644 --- a/docs/rules.rst +++ b/docs/rules.rst @@ -1,5 +1,7 @@ .. currentmodule:: grid2op.Rules +.. _rule-module: + Rules of the Game =================================== diff --git a/docs/special.rst b/docs/special.rst new file mode 100644 index 000000000..44bcdfb87 --- /dev/null +++ b/docs/special.rst @@ -0,0 +1,108 @@ +.. Color profiles for Sphinx. +.. Has to be used with hacks.css +.. (https://bitbucket.org/lbesson/web-sphinx/src/master/.static/hacks.css) +.. role:: black +.. role:: gray +.. role:: grey +.. role:: silver +.. role:: white +.. role:: maroon +.. role:: red +.. role:: magenta +.. role:: fuchsia +.. role:: pink +.. role:: orange +.. role:: yellow +.. role:: lime +.. role:: green +.. role:: olive +.. role:: teal +.. role:: cyan +.. role:: aqua +.. role:: blue +.. role:: navy +.. role:: purple + +.. role:: under +.. role:: over +.. role:: blink +.. role:: line +.. role:: strike + +.. role:: it +.. role:: ob + +.. role:: small +.. role:: large + +.. role:: center +.. role:: left +.. role:: right +.. (c) Lilian Besson, 2011-2016, https://bitbucket.org/lbesson/web-sphinx/ + +.. _n_gen: ./space.html#grid2op.Space.GridObjects.n_gen +.. _n_load: ./space.html#grid2op.Space.GridObjects.n_load +.. _n_line: ./space.html#grid2op.Space.GridObjects.n_line +.. _n_sub: ./space.html#grid2op.Space.GridObjects.n_sub +.. _n_storage: ./space.html#grid2op.Space.GridObjects.n_storage +.. _dim_topo: ./space.html#grid2op.Space.GridObjects.dim_topo +.. _dim_alarms: ./space.html#grid2op.Space.GridObjects.dim_alarms +.. _dim_alerts: ./space.html#grid2op.Space.GridObjects.dim_alerts +.. _year: ./observation.html#grid2op.Observation.BaseObservation.year +.. _month: ./observation.html#grid2op.Observation.BaseObservation.month +.. _day: ./observation.html#grid2op.Observation.BaseObservation.day +.. _hour_of_day: ./observation.html#grid2op.Observation.BaseObservation.hour_of_day +.. _minute_of_hour: ./observation.html#grid2op.Observation.BaseObservation.minute_of_hour +.. _day_of_week: ./observation.html#grid2op.Observation.BaseObservation.day_of_week +.. _gen_p: ./observation.html#grid2op.Observation.BaseObservation.gen_p +.. _gen_q: ./observation.html#grid2op.Observation.BaseObservation.gen_q +.. _gen_v: ./observation.html#grid2op.Observation.BaseObservation.gen_v +.. _load_p: ./observation.html#grid2op.Observation.BaseObservation.load_p +.. _load_q: ./observation.html#grid2op.Observation.BaseObservation.load_q +.. _load_v: ./observation.html#grid2op.Observation.BaseObservation.load_v +.. _p_or: ./observation.html#grid2op.Observation.BaseObservation.p_or +.. _q_or: ./observation.html#grid2op.Observation.BaseObservation.q_or +.. _v_or: ./observation.html#grid2op.Observation.BaseObservation.v_or +.. _a_or: ./observation.html#grid2op.Observation.BaseObservation.a_or +.. _p_ex: ./observation.html#grid2op.Observation.BaseObservation.p_ex +.. _q_ex: ./observation.html#grid2op.Observation.BaseObservation.q_ex +.. _v_ex: ./observation.html#grid2op.Observation.BaseObservation.v_ex +.. _a_ex: ./observation.html#grid2op.Observation.BaseObservation.a_ex +.. _rho: ./observation.html#grid2op.Observation.BaseObservation.rho +.. _topo_vect: ./observation.html#grid2op.Observation.BaseObservation.topo_vect +.. _line_status: ./observation.html#grid2op.Observation.BaseObservation.line_status +.. _timestep_overflow: ./observation.html#grid2op.Observation.BaseObservation.timestep_overflow +.. _time_before_cooldown_line: ./observation.html#grid2op.Observation.BaseObservation.time_before_cooldown_line +.. _time_before_cooldown_sub: ./observation.html#grid2op.Observation.BaseObservation.time_before_cooldown_sub +.. _time_next_maintenance: ./observation.html#grid2op.Observation.BaseObservation.time_next_maintenance +.. _duration_next_maintenance: ./observation.html#grid2op.Observation.BaseObservation.duration_next_maintenance +.. _target_dispatch: ./observation.html#grid2op.Observation.BaseObservation.target_dispatch +.. _actual_dispatch: ./observation.html#grid2op.Observation.BaseObservation.actual_dispatch +.. _storage_charge: ./observation.html#grid2op.Observation.BaseObservation.storage_charge +.. _storage_power_target: ./observation.html#grid2op.Observation.BaseObservation.storage_power_target +.. _storage_power: ./observation.html#grid2op.Observation.BaseObservation.storage_power +.. _storage_theta: ./observation.html#grid2op.Observation.BaseObservation.storage_theta +.. _gen_p_before_curtail: ./observation.html#grid2op.Observation.BaseObservation.gen_p_before_curtail +.. _curtailment: ./observation.html#grid2op.Observation.BaseObservation.curtailment +.. _curtailment_limit: ./observation.html#grid2op.Observation.BaseObservation.curtailment_limit +.. _is_alarm_illegal: ./observation.html#grid2op.Observation.BaseObservation.is_alarm_illegal +.. _time_since_last_alarm: ./observation.html#grid2op.Observation.BaseObservation.time_since_last_alarm +.. _last_alarm: ./observation.html#grid2op.Observation.BaseObservation.last_alarm +.. _attention_budget: ./observation.html#grid2op.Observation.BaseObservation.attention_budget +.. _max_step: ./observation.html#grid2op.Observation.BaseObservation.max_step +.. _current_step: ./observation.html#grid2op.Observation.BaseObservation.current_step +.. _delta_time: ./observation.html#grid2op.Observation.BaseObservation.delta_time +.. _gen_margin_up: ./observation.html#grid2op.Observation.BaseObservation.gen_margin_up +.. _gen_margin_down: ./observation.html#grid2op.Observation.BaseObservation.gen_margin_down +.. _curtailment_mw: ./observation.html#grid2op.Observation.BaseObservation.curtailment_mw +.. _theta_or: ./observation.html#grid2op.Observation.BaseObservation.theta_or +.. _theta_ex: ./observation.html#grid2op.Observation.BaseObservation.theta_ex +.. _gen_theta: ./observation.html#grid2op.Observation.BaseObservation.gen_theta +.. _load_theta: ./observation.html#grid2op.Observation.BaseObservation.load_theta +.. _active_alert: ./observation.html#grid2op.Observation.BaseObservation.active_alert +.. _time_since_last_alert: ./observation.html#grid2op.Observation.BaseObservation.time_since_last_alert +.. _alert_duration: ./observation.html#grid2op.Observation.BaseObservation.alert_duration +.. _total_number_of_alert: ./observation.html#grid2op.Observation.BaseObservation.total_number_of_alert +.. _time_since_last_attack: ./observation.html#grid2op.Observation.BaseObservation.time_since_last_attack +.. _was_alert_used_after_attack: ./observation.html#grid2op.Observation.BaseObservation.was_alert_used_after_attack +.. _attack_under_alert: ./observation.html#grid2op.Observation.BaseObservation.attack_under_alert diff --git a/docs/voltagecontroler.rst b/docs/voltagecontroler.rst index eb7b902f3..19e391297 100644 --- a/docs/voltagecontroler.rst +++ b/docs/voltagecontroler.rst @@ -1,5 +1,8 @@ .. currentmodule:: grid2op.VoltageControler +.. _voltage-controler-module: + + Voltage Controler =================================== diff --git a/examples/backend_integration/Step1_loading.py b/examples/backend_integration/Step1_loading.py index a456a2106..4775ba85d 100644 --- a/examples/backend_integration/Step1_loading.py +++ b/examples/backend_integration/Step1_loading.py @@ -30,6 +30,8 @@ # to serve as an example import pandapower as pp +ERR_MSG_ELSEWHERE = "Will be detailed in another example script" + class CustomBackend_Step1(Backend): def load_grid(self, @@ -97,25 +99,25 @@ def load_grid(self, self._compute_pos_big_topo() def apply_action(self, backendAction: Union["grid2op.Action._backendAction._BackendAction", None]) -> None: - raise NotImplementedError("Will be detailed in another example script") + raise NotImplementedError() def runpf(self, is_dc : bool=False) -> Tuple[bool, Union[Exception, None]]: - raise NotImplementedError("Will be detailed in another example script") + raise NotImplementedError(ERR_MSG_ELSEWHERE) def get_topo_vect(self) -> np.ndarray: - raise NotImplementedError("Will be detailed in another example script") + raise NotImplementedError(ERR_MSG_ELSEWHERE) def generators_info(self)-> Tuple[np.ndarray, np.ndarray, np.ndarray]: - raise NotImplementedError("Will be detailed in another example script") + raise NotImplementedError(ERR_MSG_ELSEWHERE) def loads_info(self)-> Tuple[np.ndarray, np.ndarray, np.ndarray]: - raise NotImplementedError("Will be detailed in another example script") + raise NotImplementedError(ERR_MSG_ELSEWHERE) def lines_or_info(self)-> Tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray]: - raise NotImplementedError("Will be detailed in another example script") + raise NotImplementedError(ERR_MSG_ELSEWHERE) def lines_ex_info(self)-> Tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray]: - raise NotImplementedError("Will be detailed in another example script") + raise NotImplementedError(ERR_MSG_ELSEWHERE) if __name__ == "__main__": diff --git a/examples/backend_integration/Step4_modify_line_status.py b/examples/backend_integration/Step4_modify_line_status.py index 1f3cac741..e4e7c5057 100644 --- a/examples/backend_integration/Step4_modify_line_status.py +++ b/examples/backend_integration/Step4_modify_line_status.py @@ -224,10 +224,10 @@ def lines_ex_info(self)-> Tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray]: print(f"{q_or = }") print(f"{v_or = }") print(f"{a_or = }") - assert p_or[0] == 0. - assert q_or[0] == 0. - assert v_or[0] == 0. - assert a_or[0] == 0. + assert np.abs(p_or[0]) <= 1e-7 + assert np.abs(q_or[0]) <= 1e-7 + assert np.abs(v_or[0]) <= 1e-7 + assert np.abs(a_or[0]) <= 1e-7 # this is how "user" manipute the grid # in this I reconnect powerline 0 @@ -280,7 +280,7 @@ def lines_ex_info(self)-> Tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray]: print(f"{q_or = }") print(f"{v_or = }") print(f"{a_or = }") - assert p_or[line_id] == 0. - assert q_or[line_id] == 0. - assert v_or[line_id] == 0. - assert a_or[line_id] == 0. + assert np.abs(p_or[line_id]) <= 1e-7 + assert np.abs(q_or[line_id]) <= 1e-7 + assert np.abs(v_or[line_id]) <= 1e-7 + assert np.abs(a_or[line_id]) <= 1e-7 diff --git a/grid2op/Action/_backendAction.py b/grid2op/Action/_backendAction.py index b5e19022c..99d61c921 100644 --- a/grid2op/Action/_backendAction.py +++ b/grid2op/Action/_backendAction.py @@ -8,7 +8,13 @@ import copy import numpy as np +from typing import Tuple, Union +try: + from typing import Self +except ImportError: + from typing_extensions import Self +from grid2op.Action.baseAction import BaseAction from grid2op.dtypes import dt_int, dt_bool, dt_float from grid2op.Space import GridObjects @@ -16,17 +22,109 @@ # TODO see if it can be done in c++ easily class ValueStore: """ - INTERNAL USE ONLY + USE ONLY IF YOU WANT TO CODE A NEW BACKEND - .. warning:: /!\\\\ Internal, do not use unless you know what you are doing /!\\\\ + .. warning:: /!\\\\ Internal, do not modify, alter, change, override the implementation unless you know what you are doing /!\\\\ + If you override them you might even notice some extremely weird behaviour. It's not "on purpose", we are aware of + it but we won't change it (for now at least) + + .. warning:: + Objects from this class should never be created by anyone except by objects of the :class:`grid2op.Action._backendAction._BackendAction` + when they are created or when instances of `_BackendAction` are process *eg* with :func:`_BackendAction.__call__` or + :func:`_BackendAction.get_loads_bus` etc. + + There are two correct uses for this class: + + #. by iterating manually with the `for xxx in value_stor_instance: ` + #. by checking which objects have been changed (with :attr:`ValueStore.changed`) and then check the + new value of the elements **changed** with :attr:`ValueStore.values` [el_id] + + .. danger:: + + You should never trust the values in :attr:`ValueStore.values` [el_id] if :attr:`ValueStore.changed` [el_id] is `False`. + + Access data (values) only when the corresponding "mask" (:attr:`ValueStore.changed`) is `True`. + + This is, of course, ensured by default if you use the practical way of iterating through them with: + + .. code-block:: python + + load_p: ValueStore # a ValueStore object named "load_p" + + for load_id, new_p in load_p: + # do something + + In this case only "new_p" will be given if corresponding `changed` mask is true. + + Attributes + ---------- + + + Examples + --------- + + Say you have a "ValueStore" `val_sto` (in :class:`grid2op.Action._backendAction._BackendAction` you will end up manipulating + pretty much all the time `ValueStore` if you use it correctly, with :func:`_BackendAction.__call__` but also is you call + :func:`_BackendAction.get_loads_bus`, :func:`_BackendAction.get_loads_bus_global`, :func:`_BackendAction.get_gens_bus`, ...) + + Basically, the "variables" named `prod_p`, `prod_v`, `load_p`, `load_q`, `storage_p`, + `topo__`, `shunt_p`, `shunt_q`, `shunt_bus`, `backendAction.get_lines_or_bus()`, + `backendAction.get_lines_or_bus_global()`, etc in the doc of :class:`grid2op.Action._backendAction._BackendAction` + are all :class:`ValueStore`. + + Recommended usage: + + .. code-block:: python + + val_sto: ValueStore # a ValueStore object named "val_sto" + + for el_id, new_val in val_sto: + # do something + + # less abstractly, say `load_p` is a ValueStore: + # for load_id, new_p in load_p: + # do the real changes of load active value in self._grid + # load_id => id of loads for which the active consumption changed + # new_p => new load active consumption for `load_id` + # self._grid.change_load_active_value(load_id, new_p) # fictive example of course... + + + More advanced / vectorized usage (only do that if you found out your backend was + slow because of the iteration in python above, this is error-prone and in general + might not be worth it...): + + .. code-block:: python + + val_sto: ValueStore # a ValueStore object named "val_sto" + + # less abstractly, say `load_p` is a ValueStore: + # self._grid.change_all_loads_active_value(where_changed=load_p.changed, + new_vals=load_p.values[load_p.changed]) + # fictive example of couse, I highly doubt the self._grid + # implements a method named exactly `change_all_loads_active_value` + + WARNING, DANGER AHEAD: + Never trust the data in load_p.values[~load_p.changed], they might even be un intialized... + """ def __init__(self, size, dtype): ## TODO at the init it's mandatory to have everything at "1" here # if topo is not "fully connected" it will not work + + #: :class:`np.ndarray` + #: The new target values to be set in `backend._grid` in `apply_action` + #: never use the values if the corresponding mask is set to `False` + #: (it might be non initialized). self.values = np.empty(size, dtype=dtype) + + #: :class:`np.ndarray` (bool) + #: Mask representing which values (stored in :attr:`ValueStore.values` ) are + #: meaningful. The other values (corresponding to `changed=False` ) are meaningless. self.changed = np.full(size, dtype=dt_bool, fill_value=False) + + #: used internally for iteration self.last_index = 0 self.__size = size @@ -53,7 +151,7 @@ def _change_val_int(self, newvals): self.values[changed_] = (1 - self.values[changed_]) + 2 def _change_val_float(self, newvals): - changed_ = newvals != 0.0 + changed_ = np.abs(newvals) >= 1e-7 self.changed[changed_] = True self.values[changed_] += newvals[changed_] @@ -199,6 +297,10 @@ def force_unchanged(self, mask, local_bus): to_unchanged = local_bus == -1 to_unchanged[~mask] = False self.changed[to_unchanged] = False + + def register_new_topo(self, current_topo: "ValueStore"): + mask_co = current_topo.values >= 1 + self.values[mask_co] = current_topo.values[mask_co] class _BackendAction(GridObjects): @@ -207,47 +309,211 @@ class _BackendAction(GridObjects): Internal class, use at your own risk. - This class "digest" the players / environment / opponent / voltage controlers "action", - and transform it to setpoint for the backend. + This class "digest" the players / environment / opponent / voltage controlers "actions", + and transform it to one single "state" that can in turn be process by the backend + in the function :func:`grid2op.Backend.Backend.apply_action`. + + .. note:: + In a :class:`_BackendAction` only the state of the element that have been modified + by an "entity" (agent, environment, opponent, voltage controler etc.) is given. + + We expect the backend to "remember somehow" the state of all the rest. + + This is to save a lot of computation time for larger grid. + + .. note:: + You probably don't need to import the `_BackendAction` class (this is why + we "hide" it), + but the `backendAction` you will receive in `apply_action` is indeed + a :class:`_BackendAction`, hence this documentation. + + If you want to use grid2op to develop agents or new time series, + this class should behave transparently for you and you don't really + need to spend time reading its documentation. + + If you want to develop in grid2op and code a new backend, you might be interested in: + + - :func:`_BackendAction.__call__` + - :func:`_BackendAction.get_loads_bus` + - :func:`_BackendAction.get_loads_bus_global` + - :func:`_BackendAction.get_gens_bus` + - :func:`_BackendAction.get_gens_bus_global` + - :func:`_BackendAction.get_lines_or_bus` + - :func:`_BackendAction.get_lines_or_bus_global` + - :func:`_BackendAction.get_lines_ex_bus` + - :func:`_BackendAction.get_lines_ex_bus_global` + - :func:`_BackendAction.get_storages_bus` + - :func:`_BackendAction.get_storages_bus_global` + - :func:`_BackendAction.get_shunts_bus_global` + + And in this case, for usage examples, see the examples available in: + + - https://github.com/rte-france/Grid2Op/tree/master/examples/backend_integration: a step by step guide to + code a new backend + - :class:`grid2op.Backend.educPandaPowerBackend.EducPandaPowerBackend` and especially the + :func:`grid2op.Backend.educPandaPowerBackend.EducPandaPowerBackend.apply_action` + - :ref:`create-backend-module` page of the documentation, especially the + :ref:`backend-action-create-backend` section + + Otherwise, "TL;DR" (only relevant when you want to implement the :func:`grid2op.Backend.Backend.apply_action` + function, rest is not shown): + + .. code-block:: python + + def apply_action(self, backendAction: Union["grid2op.Action._backendAction._BackendAction", None]) -> None: + if backendAction is None: + return + + ( + active_bus, + (prod_p, prod_v, load_p, load_q, storage_p), + topo__, + shunts__, + ) = backendAction() + + # change the active values of the loads + for load_id, new_p in load_p: + # do the real changes in self._grid + + # change the reactive values of the loads + for load_id, new_q in load_q: + # do the real changes in self._grid + + # change the active value of generators + for gen_id, new_p in prod_p: + # do the real changes in self._grid + + # for the voltage magnitude, pandapower expects pu but grid2op provides kV, + # so we need a bit of change + for gen_id, new_v in prod_v: + # do the real changes in self._grid + + # process the topology : + + # option 1: you can directly set the element of the grid in the "topo_vect" + # order, for example you can modify in your solver the busbar to which + # element 17 of `topo_vect` is computed (this is necessarily a local view of + # the buses ) + for el_topo_vect_id, new_el_bus in topo__: + # connect this object to the `new_el_bus` (local) in self._grid + + # OR !!! (use either option 1 or option 2.a or option 2.b - exclusive OR) + + # option 2: use "per element type" view (this is usefull) + # if your solver has organized its data by "type" and you can + # easily access "all loads" and "all generators" etc. + + # option 2.a using "local view": + # new_bus is either -1, 1, 2, ..., backendAction.n_busbar_per_sub + lines_or_bus = backendAction.get_lines_or_bus() + for line_id, new_bus in lines_or_bus: + # connect "or" side of "line_id" to (local) bus `new_bus` in self._grid + + # OR !!! (use either option 1 or option 2.a or option 2.b - exclusive OR) + + # option 2.b using "global view": + # new_bus is either 0, 1, 2, ..., backendAction.n_busbar_per_sub * backendAction.n_sub + # (this suppose internally that your solver and grid2op have the same + # "ways" of labelling the buses...) + lines_or_bus = backendAction.get_lines_or_bus_global() + for line_id, new_bus in lines_or_bus: + # connect "or" side of "line_id" to (global) bus `new_bus` in self._grid + + # now repeat option a OR b calling the right methods + # for each element types (*eg* get_lines_ex_bus, get_loads_bus, get_gens_bus, + # get_storages_bus for "option a-like") + + ######## end processing of the topology ############### + + # now implement the shunts: + + if shunts__ is not None: + shunt_p, shunt_q, shunt_bus = shunts__ + + if (shunt_p.changed).any(): + # p has changed for at least a shunt + for shunt_id, new_shunt_p in shunt_p: + # do the real changes in self._grid + + if (shunt_q.changed).any(): + # q has changed for at least a shunt + for shunt_id, new_shunt_q in shunt_q: + # do the real changes in self._grid + + if (shunt_bus.changed).any(): + # at least one shunt has been disconnected + # or has changed the buses + + # do like for normal topology with: + # option a -like (using local bus): + for shunt_id, new_shunt_bus in shunt_bus: + ... + # OR + # option b -like (using global bus): + shunt_global_bus = backendAction.get_shunts_bus_global() + for shunt_id, new_shunt_bus in shunt_global_bus: + # connect shunt_id to (global) bus `new_shunt_bus` in self._grid + + .. warning:: + The steps shown here are generic and might not be optimised for your backend. This + is why you probably do not see any of them directly in :class:`grid2op.Backend.PandaPowerBackend` + (where everything is vectorized to make things fast **with pandapower**). + + It is probably a good idea to first get this first implementation up and running, passing + all the tests, and then to worry about optimization: + + The real problem is that programmers have spent far too much + time worrying about efficiency in the wrong places and at the wrong times; + premature optimization is the root of all evil (or at least most of it) + in programming. + + Donald Knuth, "*The Art of Computer Programming*" + """ def __init__(self): + """ + .. warning:: /!\\\\ Internal, do not use unless you know what you are doing /!\\\\ + + This is handled by the environment ! + + """ GridObjects.__init__(self) + cls = type(self) # last connected registered - self.last_topo_registered = ValueStore(self.dim_topo, dtype=dt_int) + self.last_topo_registered: ValueStore = ValueStore(cls.dim_topo, dtype=dt_int) # topo at time t - self.current_topo = ValueStore(self.dim_topo, dtype=dt_int) + self.current_topo: ValueStore = ValueStore(cls.dim_topo, dtype=dt_int) # by default everything is on busbar 1 self.last_topo_registered.values[:] = 1 self.current_topo.values[:] = 1 # injection at time t - self.prod_p = ValueStore(self.n_gen, dtype=dt_float) - self.prod_v = ValueStore(self.n_gen, dtype=dt_float) - self.load_p = ValueStore(self.n_load, dtype=dt_float) - self.load_q = ValueStore(self.n_load, dtype=dt_float) - self.storage_power = ValueStore(self.n_storage, dtype=dt_float) - - self.activated_bus = np.full((self.n_sub, 2), dtype=dt_bool, fill_value=False) - self.big_topo_to_subid = np.repeat( - list(range(self.n_sub)), repeats=self.sub_info + self.prod_p: ValueStore = ValueStore(cls.n_gen, dtype=dt_float) + self.prod_v: ValueStore = ValueStore(cls.n_gen, dtype=dt_float) + self.load_p: ValueStore = ValueStore(cls.n_load, dtype=dt_float) + self.load_q: ValueStore = ValueStore(cls.n_load, dtype=dt_float) + self.storage_power: ValueStore = ValueStore(cls.n_storage, dtype=dt_float) + + self.activated_bus = np.full((cls.n_sub, cls.n_busbar_per_sub), dtype=dt_bool, fill_value=False) + self.big_topo_to_subid: np.ndarray = np.repeat( + list(range(cls.n_sub)), repeats=cls.sub_info ) # shunts - cls = type(self) if cls.shunts_data_available: - self.shunt_p = ValueStore(self.n_shunt, dtype=dt_float) - self.shunt_q = ValueStore(self.n_shunt, dtype=dt_float) - self.shunt_bus = ValueStore(self.n_shunt, dtype=dt_int) - self.current_shunt_bus = ValueStore(self.n_shunt, dtype=dt_int) + self.shunt_p: ValueStore = ValueStore(cls.n_shunt, dtype=dt_float) + self.shunt_q: ValueStore = ValueStore(cls.n_shunt, dtype=dt_float) + self.shunt_bus: ValueStore = ValueStore(cls.n_shunt, dtype=dt_int) + self.current_shunt_bus: ValueStore = ValueStore(cls.n_shunt, dtype=dt_int) self.current_shunt_bus.values[:] = 1 - self._status_or_before = np.ones(self.n_line, dtype=dt_int) - self._status_ex_before = np.ones(self.n_line, dtype=dt_int) - self._status_or = np.ones(self.n_line, dtype=dt_int) - self._status_ex = np.ones(self.n_line, dtype=dt_int) + self._status_or_before: np.ndarray = np.ones(cls.n_line, dtype=dt_int) + self._status_ex_before: np.ndarray = np.ones(cls.n_line, dtype=dt_int) + self._status_or: np.ndarray = np.ones(cls.n_line, dtype=dt_int) + self._status_ex: np.ndarray = np.ones(cls.n_line, dtype=dt_int) self._loads_bus = None self._gens_bus = None @@ -255,7 +521,12 @@ def __init__(self): self._lines_ex_bus = None self._storage_bus = None - def __deepcopy__(self, memodict={}): + def __deepcopy__(self, memodict={}) -> Self: + + """ + .. warning:: /!\\\\ Internal, do not use unless you know what you are doing /!\\\\ + + """ res = type(self)() # last connected registered res.last_topo_registered.copy(self.last_topo_registered) @@ -287,15 +558,22 @@ def __deepcopy__(self, memodict={}): return res - def __copy__(self): + def __copy__(self) -> Self: + + """ + .. warning:: /!\\\\ Internal, do not use unless you know what you are doing /!\\\\ + + """ res = self.__deepcopy__() # nothing less to do return res - def reorder(self, no_load, no_gen, no_topo, no_storage, no_shunt): + def reorder(self, no_load, no_gen, no_topo, no_storage, no_shunt) -> None: """ .. warning:: /!\\\\ Internal, do not use unless you know what you are doing /!\\\\ + + This is handled by BackendConverter, do not alter - reorder the element modified, this is use when converting backends only and should not be use + Reorder the element modified, this is use when converting backends only and should not be use outside of this usecase no_* stands for "new order" @@ -316,8 +594,14 @@ def reorder(self, no_load, no_gen, no_topo, no_storage, no_shunt): self.shunt_bus.reorder(no_shunt) self.current_shunt_bus.reorder(no_shunt) - def reset(self): - # last topo + def reset(self) -> None: + """ + .. warning:: /!\\\\ Internal, do not use unless you know what you are doing /!\\\\ + + This is called by the environment, do not alter. + + """ + # last known topo self.last_topo_registered.reset() # topo at time t @@ -340,8 +624,15 @@ def reset(self): self.shunt_q.reset() self.shunt_bus.reset() self.current_shunt_bus.reset() + + self.last_topo_registered.register_new_topo(self.current_topo) - def all_changed(self): + def all_changed(self) -> None: + """ + .. warning:: /!\\\\ Internal, do not use unless you know what you are doing /!\\\\ + + This is called by the environment, do not alter. + """ # last topo self.last_topo_registered.all_changed() @@ -363,24 +654,109 @@ def all_changed(self): # self.shunt_bus.all_changed() def set_redispatch(self, new_redispatching): + """ + .. warning:: /!\\\\ Internal, do not use unless you know what you are doing /!\\\\ + + This is called by the environment, do not alter. + """ self.prod_p.change_val(new_redispatching) - def __iadd__(self, other): + def _aux_iadd_inj(self, dict_injection): + """ + .. warning:: /!\\\\ Internal, do not use unless you know what you are doing /!\\\\ + + Internal implementation of += + + """ + if "load_p" in dict_injection: + tmp = dict_injection["load_p"] + self.load_p.set_val(tmp) + if "load_q" in dict_injection: + tmp = dict_injection["load_q"] + self.load_q.set_val(tmp) + if "prod_p" in dict_injection: + tmp = dict_injection["prod_p"] + self.prod_p.set_val(tmp) + if "prod_v" in dict_injection: + tmp = dict_injection["prod_v"] + self.prod_v.set_val(tmp) + + def _aux_iadd_shunt(self, other): """ .. warning:: /!\\\\ Internal, do not use unless you know what you are doing /!\\\\ + + Internal implementation of += + + """ + shunts = {} + if type(other).shunts_data_available: + shunts["shunt_p"] = other.shunt_p + shunts["shunt_q"] = other.shunt_q + shunts["shunt_bus"] = other.shunt_bus - other: a grid2op action standard + arr_ = shunts["shunt_p"] + self.shunt_p.set_val(arr_) + arr_ = shunts["shunt_q"] + self.shunt_q.set_val(arr_) + arr_ = shunts["shunt_bus"] + self.shunt_bus.set_val(arr_) + self.current_shunt_bus.values[self.shunt_bus.changed] = self.shunt_bus.values[self.shunt_bus.changed] + + def _aux_iadd_reconcile_disco_reco(self): + """ + .. warning:: /!\\\\ Internal, do not use unless you know what you are doing /!\\\\ + + Internal implementation of += + + """ + disco_or = (self._status_or_before == -1) | (self._status_or == -1) + disco_ex = (self._status_ex_before == -1) | (self._status_ex == -1) + disco_now = ( + disco_or | disco_ex + ) # a powerline is disconnected if at least one of its extremity is + # added + reco_or = (self._status_or_before == -1) & (self._status_or >= 1) + reco_ex = (self._status_or_before == -1) & (self._status_ex >= 1) + reco_now = reco_or | reco_ex + # Set nothing + set_now = np.zeros_like(self._status_or) + # Force some disconnections + set_now[disco_now] = -1 + set_now[reco_now] = 1 + + self.current_topo.set_status( + set_now, + self.line_or_pos_topo_vect, + self.line_ex_pos_topo_vect, + self.last_topo_registered, + ) + + def __iadd__(self, other : BaseAction) -> Self: + """ + .. warning:: /!\\\\ Internal, do not use unless you know what you are doing /!\\\\ + + This is called by the environment, do not alter. + + The goal of this function is to "fused" together all the different types + of modifications handled by: + + - the Agent + - the opponent + - the time series (part of the environment) + - the voltage controler + + It might be called multiple times per step. Parameters ---------- - other: :class:`grid2op.Action.BaseAction.BaseAction` + other: :class:`grid2op.Action.BaseAction` Returns ------- - + The updated state of `self` after the new action `other` has been added to it. + """ - dict_injection = other._dict_inj set_status = other._set_line_status switch_status = other._switch_line_status set_topo_vect = other._set_topo_vect @@ -391,19 +767,8 @@ def __iadd__(self, other): # I deal with injections # Ia set the injection if other._modif_inj: - if "load_p" in dict_injection: - tmp = dict_injection["load_p"] - self.load_p.set_val(tmp) - if "load_q" in dict_injection: - tmp = dict_injection["load_q"] - self.load_q.set_val(tmp) - if "prod_p" in dict_injection: - tmp = dict_injection["prod_p"] - self.prod_p.set_val(tmp) - if "prod_v" in dict_injection: - tmp = dict_injection["prod_v"] - self.prod_v.set_val(tmp) - + self._aux_iadd_inj(other._dict_inj) + # Ib change the injection aka redispatching if other._modif_redispatch: self.prod_p.change_val(redispatching) @@ -414,20 +779,8 @@ def __iadd__(self, other): # II shunts if type(self).shunts_data_available: - shunts = {} - if type(other).shunts_data_available: - shunts["shunt_p"] = other.shunt_p - shunts["shunt_q"] = other.shunt_q - shunts["shunt_bus"] = other.shunt_bus - - arr_ = shunts["shunt_p"] - self.shunt_p.set_val(arr_) - arr_ = shunts["shunt_q"] - self.shunt_q.set_val(arr_) - arr_ = shunts["shunt_bus"] - self.shunt_bus.set_val(arr_) - self.current_shunt_bus.values[self.shunt_bus.changed] = self.shunt_bus.values[self.shunt_bus.changed] - + self._aux_iadd_shunt(other) + # III line status # this need to be done BEFORE the topology, as a connected powerline will be connected to their old bus. # regardless if the status is changed in the action or not. @@ -468,47 +821,78 @@ def __iadd__(self, other): # At least one disconnected extremity if other._modif_change_bus or other._modif_set_bus: - disco_or = (self._status_or_before == -1) | (self._status_or == -1) - disco_ex = (self._status_ex_before == -1) | (self._status_ex == -1) - disco_now = ( - disco_or | disco_ex - ) # a powerline is disconnected if at least one of its extremity is - # added - reco_or = (self._status_or_before == -1) & (self._status_or >= 1) - reco_ex = (self._status_or_before == -1) & (self._status_ex >= 1) - reco_now = reco_or | reco_ex - # Set nothing - set_now = np.zeros_like(self._status_or) - # Force some disconnections - set_now[disco_now] = -1 - set_now[reco_now] = 1 - - self.current_topo.set_status( - set_now, - self.line_or_pos_topo_vect, - self.line_ex_pos_topo_vect, - self.last_topo_registered, - ) - + self._aux_iadd_reconcile_disco_reco() return self - def _assign_0_to_disco_el(self): - """do not consider disconnected elements are modified for there active / reactive / voltage values""" - gen_changed = self.current_topo.changed[type(self).gen_pos_topo_vect] - gen_bus = self.current_topo.values[type(self).gen_pos_topo_vect] + def _assign_0_to_disco_el(self) -> None: + """ + .. warning:: /!\\\\ Internal, do not use unless you know what you are doing /!\\\\ + + This is handled by the environment, do not alter. + + Do not consider disconnected elements are modified for there active / reactive / voltage values + """ + cls = type(self) + gen_changed = self.current_topo.changed[cls.gen_pos_topo_vect] + gen_bus = self.current_topo.values[cls.gen_pos_topo_vect] self.prod_p.force_unchanged(gen_changed, gen_bus) self.prod_v.force_unchanged(gen_changed, gen_bus) - load_changed = self.current_topo.changed[type(self).load_pos_topo_vect] - load_bus = self.current_topo.values[type(self).load_pos_topo_vect] + load_changed = self.current_topo.changed[cls.load_pos_topo_vect] + load_bus = self.current_topo.values[cls.load_pos_topo_vect] self.load_p.force_unchanged(load_changed, load_bus) self.load_q.force_unchanged(load_changed, load_bus) - sto_changed = self.current_topo.changed[type(self).storage_pos_topo_vect] - sto_bus = self.current_topo.values[type(self).storage_pos_topo_vect] + sto_changed = self.current_topo.changed[cls.storage_pos_topo_vect] + sto_bus = self.current_topo.values[cls.storage_pos_topo_vect] self.storage_power.force_unchanged(sto_changed, sto_bus) - def __call__(self): + def __call__(self) -> Tuple[np.ndarray, + Tuple[ValueStore, ValueStore, ValueStore, ValueStore, ValueStore], + ValueStore, + Union[Tuple[ValueStore, ValueStore, ValueStore], None]]: + """ + This function should be called at the top of the :func:`grid2op.Backend.Backend.apply_action` + implementation when you decide to code a new backend. + + It processes the state of the backend into a form "easy to use" in the `apply_action` method. + + .. danger:: + It is mandatory to call it, otherwise some features might not work. + + .. warning:: /!\\\\ Do not alter / modify / change / override this implementation /!\\\\ + + Examples + ----------- + + A typical implementation of `apply_action` will start with: + + .. code-block:: python + + def apply_action(self, backendAction: Union["grid2op.Action._backendAction._BackendAction", None]) -> None: + if backendAction is None: + return + + ( + active_bus, + (prod_p, prod_v, load_p, load_q, storage), + topo__, + shunts__, + ) = backendAction() + + # process the backend action by updating `self._grid` + + Returns + ------- + + - `active_bus`: matrix with `type(self).n_sub` rows and `type(self).n_busbar_per_bus` columns. Each elements + represents a busbars of the grid. ``False`` indicates that nothing is connected to this busbar and ``True`` + means that at least an element is connected to this busbar + - (prod_p, prod_v, load_p, load_q, storage): 5-tuple of Iterable to set the new values of generators, loads and storage units. + - topo: iterable representing the target topology (in local bus, elements are ordered with their + position in the `topo_vect` vector) + + """ self._assign_0_to_disco_el() injections = ( self.prod_p, @@ -524,68 +908,512 @@ def __call__(self): self._get_active_bus() return self.activated_bus, injections, topo, shunts - def get_loads_bus(self): + def get_loads_bus(self) -> ValueStore: + """ + This function might be called in the implementation of :func:`grid2op.Backend.Backend.apply_action`. + + It is relevant when your solver expose API by "element types" for example + you get the possibility to set and access all loads at once, all generators at + once and your solver can easily move element from different busbar in a given + substation. + + This corresponds to option 2a described (shortly) in :class:`_BackendAction`. + + In this setting, this function will give you the "local bus" id for each loads that + have been changed by the agent / time series / voltage controlers / opponent / etc. + + .. warning:: /!\\\\ Do not alter / modify / change / override this implementation /!\\\\ + + .. seealso:: + The other related functions: + + - :func:`_BackendAction.get_loads_bus` + - :func:`_BackendAction.get_gens_bus` + - :func:`_BackendAction.get_lines_or_bus` + - :func:`_BackendAction.get_lines_ex_bus` + - :func:`_BackendAction.get_storages_bus` + + Examples + ----------- + + A typical use of `get_loads_bus` in `apply_action` is: + + .. code-block:: python + + def apply_action(self, backendAction: Union["grid2op.Action._backendAction._BackendAction", None]) -> None: + if backendAction is None: + return + + ( + active_bus, + (prod_p, prod_v, load_p, load_q, storage), + _, + shunts__, + ) = backendAction() + + # process the backend action by updating `self._grid` + ... + + # now process the topology (called option 2.a in the doc): + + lines_or_bus = backendAction.get_lines_or_bus() + for line_id, new_bus in lines_or_bus: + # connect "or" side of "line_id" to (local) bus `new_bus` in self._grid + self._grid.something(...) + # or + self._grid.something = ... + + lines_ex_bus = backendAction.get_lines_ex_bus() + for line_id, new_bus in lines_ex_bus: + # connect "ex" side of "line_id" to (local) bus `new_bus` in self._grid + self._grid.something(...) + # or + self._grid.something = ... + + storages_bus = backendAction.get_storages_bus() + for el_id, new_bus in storages_bus: + # connect storage id `el_id` to (local) bus `new_bus` in self._grid + self._grid.something(...) + # or + self._grid.something = ... + + gens_bus = backendAction.get_gens_bus() + for el_id, new_bus in gens_bus: + # connect generator id `el_id` to (local) bus `new_bus` in self._grid + self._grid.something(...) + # or + self._grid.something = ... + + loads_bus = backendAction.get_loads_bus() + for el_id, new_bus in loads_bus: + # connect generator id `el_id` to (local) bus `new_bus` in self._grid + self._grid.something(...) + # or + self._grid.something = ... + + # continue implementation of `apply_action` + + """ if self._loads_bus is None: - self._loads_bus = ValueStore(self.n_load, dtype=dt_int) - self._loads_bus.copy_from_index(self.current_topo, self.load_pos_topo_vect) + self._loads_bus = ValueStore(type(self).n_load, dtype=dt_int) + self._loads_bus.copy_from_index(self.current_topo, type(self).load_pos_topo_vect) return self._loads_bus - def _aux_to_global(self, value_store, to_subid): + def _aux_to_global(self, value_store, to_subid) -> ValueStore: value_store = copy.deepcopy(value_store) value_store.values = type(self).local_bus_to_global(value_store.values, to_subid) return value_store - def get_loads_bus_global(self): + def get_loads_bus_global(self) -> ValueStore: + """ + This function might be called in the implementation of :func:`grid2op.Backend.Backend.apply_action`. + + It is relevant when your solver expose API by "element types" for example + you get the possibility to set and access all loads at once, all generators at + once AND you can easily switch element from one "busbars" to another in + the whole grid handled by your solver. + + This corresponds to situation 2b described in :class:`_BackendAction`. + + In this setting, this function will give you the "local bus" id for each loads that + have been changed by the agent / time series / voltage controlers / opponent / etc. + + .. warning:: /!\\\\ Do not alter / modify / change / override this implementation /!\\\\ + + .. seealso:: + The other related functions: + + - :func:`_BackendAction.get_loads_bus_global` + - :func:`_BackendAction.get_gens_bus_global` + - :func:`_BackendAction.get_lines_or_bus_global` + - :func:`_BackendAction.get_lines_ex_bus_global` + - :func:`_BackendAction.get_storages_bus_global` + + Examples + ----------- + + A typical use of `get_loads_bus_global` in `apply_action` is: + + .. code-block:: python + + def apply_action(self, backendAction: Union["grid2op.Action._backendAction._BackendAction", None]) -> None: + if backendAction is None: + return + + ( + active_bus, + (prod_p, prod_v, load_p, load_q, storage), + _, + shunts__, + ) = backendAction() + + # process the backend action by updating `self._grid` + ... + + # now process the topology (called option 2.a in the doc): + + lines_or_bus = backendAction.get_lines_or_bus_global() + for line_id, new_bus in lines_or_bus: + # connect "or" side of "line_id" to (global) bus `new_bus` in self._grid + self._grid.something(...) + # or + self._grid.something = ... + + lines_ex_bus = backendAction.get_lines_ex_bus_global() + for line_id, new_bus in lines_ex_bus: + # connect "ex" side of "line_id" to (global) bus `new_bus` in self._grid + self._grid.something(...) + # or + self._grid.something = ... + + storages_bus = backendAction.get_storages_bus_global() + for el_id, new_bus in storages_bus: + # connect storage id `el_id` to (global) bus `new_bus` in self._grid + self._grid.something(...) + # or + self._grid.something = ... + + gens_bus = backendAction.get_gens_bus_global() + for el_id, new_bus in gens_bus: + # connect generator id `el_id` to (global) bus `new_bus` in self._grid + self._grid.something(...) + # or + self._grid.something = ... + + loads_bus = backendAction.get_loads_bus_global() + for el_id, new_bus in loads_bus: + # connect generator id `el_id` to (global) bus `new_bus` in self._grid + self._grid.something(...) + # or + self._grid.something = ... + + # continue implementation of `apply_action` + + """ tmp_ = self.get_loads_bus() - return self._aux_to_global(tmp_, self.load_to_subid) + return self._aux_to_global(tmp_, type(self).load_to_subid) - def get_gens_bus(self): + def get_gens_bus(self) -> ValueStore: + """ + This function might be called in the implementation of :func:`grid2op.Backend.Backend.apply_action`. + + It is relevant when your solver expose API by "element types" for example + you get the possibility to set and access all loads at once, all generators at + once and your solver can easily move element from different busbar in a given + substation. + + This corresponds to option 2a described (shortly) in :class:`_BackendAction`. + + In this setting, this function will give you the "local bus" id for each generators that + have been changed by the agent / time series / voltage controlers / opponent / etc. + + .. warning:: /!\\\\ Do not alter / modify / change / override this implementation /!\\\\ + + .. seealso:: + The other related functions: + + - :func:`_BackendAction.get_loads_bus` + - :func:`_BackendAction.get_gens_bus` + - :func:`_BackendAction.get_lines_or_bus` + - :func:`_BackendAction.get_lines_ex_bus` + - :func:`_BackendAction.get_storages_bus` + + Examples + --------- + + Some examples are given in the documentation of :func:`_BackendAction.get_loads_bus` + + """ if self._gens_bus is None: - self._gens_bus = ValueStore(self.n_gen, dtype=dt_int) - self._gens_bus.copy_from_index(self.current_topo, self.gen_pos_topo_vect) + self._gens_bus = ValueStore(type(self).n_gen, dtype=dt_int) + self._gens_bus.copy_from_index(self.current_topo, type(self).gen_pos_topo_vect) return self._gens_bus - def get_gens_bus_global(self): + def get_gens_bus_global(self) -> ValueStore: + """ + This function might be called in the implementation of :func:`grid2op.Backend.Backend.apply_action`. + + It is relevant when your solver expose API by "element types" for example + you get the possibility to set and access all loads at once, all generators at + once AND you can easily switch element from one "busbars" to another in + the whole grid handled by your solver. + + This corresponds to situation 2b described in :class:`_BackendAction`. + + In this setting, this function will give you the "local bus" id for each loads that + have been changed by the agent / time series / voltage controlers / opponent / etc. + + .. warning:: /!\\\\ Do not alter / modify / change / override this implementation /!\\\\ + + .. seealso:: + The other related functions: + + - :func:`_BackendAction.get_loads_bus_global` + - :func:`_BackendAction.get_gens_bus_global` + - :func:`_BackendAction.get_lines_or_bus_global` + - :func:`_BackendAction.get_lines_ex_bus_global` + - :func:`_BackendAction.get_storages_bus_global` + + Examples + --------- + + Some examples are given in the documentation of :func:`_BackendAction.get_loads_bus_global` + """ + tmp_ = copy.deepcopy(self.get_gens_bus()) - return self._aux_to_global(tmp_, self.gen_to_subid) + return self._aux_to_global(tmp_, type(self).gen_to_subid) - def get_lines_or_bus(self): + def get_lines_or_bus(self) -> ValueStore: + """ + This function might be called in the implementation of :func:`grid2op.Backend.Backend.apply_action`. + + It is relevant when your solver expose API by "element types" for example + you get the possibility to set and access all loads at once, all generators at + once and your solver can easily move element from different busbar in a given + substation. + + This corresponds to option 2a described (shortly) in :class:`_BackendAction`. + + In this setting, this function will give you the "local bus" id for each line (or side) that + have been changed by the agent / time series / voltage controlers / opponent / etc. + + .. warning:: /!\\\\ Do not alter / modify / change / override this implementation /!\\\\ + + .. seealso:: + The other related functions: + + - :func:`_BackendAction.get_loads_bus` + - :func:`_BackendAction.get_gens_bus` + - :func:`_BackendAction.get_lines_or_bus` + - :func:`_BackendAction.get_lines_ex_bus` + - :func:`_BackendAction.get_storages_bus` + + Examples + --------- + + Some examples are given in the documentation of :func:`_BackendAction.get_loads_bus` + + """ if self._lines_or_bus is None: - self._lines_or_bus = ValueStore(self.n_line, dtype=dt_int) + self._lines_or_bus = ValueStore(type(self).n_line, dtype=dt_int) self._lines_or_bus.copy_from_index( - self.current_topo, self.line_or_pos_topo_vect + self.current_topo, type(self).line_or_pos_topo_vect ) return self._lines_or_bus - def get_lines_or_bus_global(self): + def get_lines_or_bus_global(self) -> ValueStore: + """ + This function might be called in the implementation of :func:`grid2op.Backend.Backend.apply_action`. + + It is relevant when your solver expose API by "element types" for example + you get the possibility to set and access all loads at once, all generators at + once AND you can easily switch element from one "busbars" to another in + the whole grid handled by your solver. + + This corresponds to situation 2b described in :class:`_BackendAction`. + + In this setting, this function will give you the "local bus" id for each loads that + have been changed by the agent / time series / voltage controlers / opponent / etc. + + .. warning:: /!\\\\ Do not alter / modify / change / override this implementation /!\\\\ + + .. seealso:: + The other related functions: + + - :func:`_BackendAction.get_loads_bus_global` + - :func:`_BackendAction.get_gens_bus_global` + - :func:`_BackendAction.get_lines_or_bus_global` + - :func:`_BackendAction.get_lines_ex_bus_global` + - :func:`_BackendAction.get_storages_bus_global` + + Examples + --------- + + Some examples are given in the documentation of :func:`_BackendAction.get_loads_bus_global` + """ tmp_ = self.get_lines_or_bus() - return self._aux_to_global(tmp_, self.line_or_to_subid) + return self._aux_to_global(tmp_, type(self).line_or_to_subid) - def get_lines_ex_bus(self): + def get_lines_ex_bus(self) -> ValueStore: + """ + This function might be called in the implementation of :func:`grid2op.Backend.Backend.apply_action`. + + It is relevant when your solver expose API by "element types" for example + you get the possibility to set and access all loads at once, all generators at + once and your solver can easily move element from different busbar in a given + substation. + + This corresponds to option 2a described (shortly) in :class:`_BackendAction`. + + In this setting, this function will give you the "local bus" id for each line (ex side) that + have been changed by the agent / time series / voltage controlers / opponent / etc. + + .. warning:: /!\\\\ Do not alter / modify / change / override this implementation /!\\\\ + + .. seealso:: + The other related functions: + + - :func:`_BackendAction.get_loads_bus` + - :func:`_BackendAction.get_gens_bus` + - :func:`_BackendAction.get_lines_or_bus` + - :func:`_BackendAction.get_lines_ex_bus` + - :func:`_BackendAction.get_storages_bus` + + Examples + --------- + + Some examples are given in the documentation of :func:`_BackendAction.get_loads_bus` + + """ if self._lines_ex_bus is None: - self._lines_ex_bus = ValueStore(self.n_line, dtype=dt_int) + self._lines_ex_bus = ValueStore(type(self).n_line, dtype=dt_int) self._lines_ex_bus.copy_from_index( - self.current_topo, self.line_ex_pos_topo_vect + self.current_topo, type(self).line_ex_pos_topo_vect ) return self._lines_ex_bus - def get_lines_ex_bus_global(self): + def get_lines_ex_bus_global(self) -> ValueStore: + """ + This function might be called in the implementation of :func:`grid2op.Backend.Backend.apply_action`. + + It is relevant when your solver expose API by "element types" for example + you get the possibility to set and access all loads at once, all generators at + once AND you can easily switch element from one "busbars" to another in + the whole grid handled by your solver. + + This corresponds to situation 2b described in :class:`_BackendAction`. + + In this setting, this function will give you the "local bus" id for each loads that + have been changed by the agent / time series / voltage controlers / opponent / etc. + + .. warning:: /!\\\\ Do not alter / modify / change / override this implementation /!\\\\ + + .. seealso:: + The other related functions: + + - :func:`_BackendAction.get_loads_bus_global` + - :func:`_BackendAction.get_gens_bus_global` + - :func:`_BackendAction.get_lines_or_bus_global` + - :func:`_BackendAction.get_lines_ex_bus_global` + - :func:`_BackendAction.get_storages_bus_global` + + Examples + --------- + + Some examples are given in the documentation of :func:`_BackendAction.get_loads_bus_global` + """ tmp_ = self.get_lines_ex_bus() - return self._aux_to_global(tmp_, self.line_ex_to_subid) + return self._aux_to_global(tmp_, type(self).line_ex_to_subid) - def get_storages_bus(self): + def get_storages_bus(self) -> ValueStore: + """ + This function might be called in the implementation of :func:`grid2op.Backend.Backend.apply_action`. + + It is relevant when your solver expose API by "element types" for example + you get the possibility to set and access all loads at once, all generators at + once and your solver can easily move element from different busbar in a given + substation. + + This corresponds to option 2a described (shortly) in :class:`_BackendAction`. + + In this setting, this function will give you the "local bus" id for each storage that + have been changed by the agent / time series / voltage controlers / opponent / etc. + + .. warning:: /!\\\\ Do not alter / modify / change / override this implementation /!\\\\ + + .. seealso:: + The other related functions: + + - :func:`_BackendAction.get_loads_bus` + - :func:`_BackendAction.get_gens_bus` + - :func:`_BackendAction.get_lines_or_bus` + - :func:`_BackendAction.get_lines_ex_bus` + - :func:`_BackendAction.get_storages_bus` + + Examples + --------- + + Some examples are given in the documentation of :func:`_BackendAction.get_loads_bus` + + """ if self._storage_bus is None: - self._storage_bus = ValueStore(self.n_storage, dtype=dt_int) - self._storage_bus.copy_from_index(self.current_topo, self.storage_pos_topo_vect) + self._storage_bus = ValueStore(type(self).n_storage, dtype=dt_int) + self._storage_bus.copy_from_index(self.current_topo, type(self).storage_pos_topo_vect) return self._storage_bus - def get_storages_bus_global(self): + def get_storages_bus_global(self) -> ValueStore: + """ + This function might be called in the implementation of :func:`grid2op.Backend.Backend.apply_action`. + + It is relevant when your solver expose API by "element types" for example + you get the possibility to set and access all loads at once, all generators at + once AND you can easily switch element from one "busbars" to another in + the whole grid handled by your solver. + + This corresponds to situation 2b described in :class:`_BackendAction`. + + In this setting, this function will give you the "local bus" id for each loads that + have been changed by the agent / time series / voltage controlers / opponent / etc. + + .. warning:: /!\\\\ Do not alter / modify / change / override this implementation /!\\\\ + + .. seealso:: + The other related functions: + + - :func:`_BackendAction.get_loads_bus_global` + - :func:`_BackendAction.get_gens_bus_global` + - :func:`_BackendAction.get_lines_or_bus_global` + - :func:`_BackendAction.get_lines_ex_bus_global` + - :func:`_BackendAction.get_storages_bus_global` + + Examples + --------- + + Some examples are given in the documentation of :func:`_BackendAction.get_loads_bus_global` + """ tmp_ = self.get_storages_bus() - return self._aux_to_global(tmp_, self.storage_to_subid) + return self._aux_to_global(tmp_, type(self).storage_to_subid) + + def get_shunts_bus_global(self) -> ValueStore: + """ + This function might be called in the implementation of :func:`grid2op.Backend.Backend.apply_action`. + + It is relevant when your solver expose API by "element types" for example + you get the possibility to set and access all loads at once, all generators at + once AND you can easily switch element from one "busbars" to another in + the whole grid handled by your solver. + + This corresponds to situation 2b described in :class:`_BackendAction`. + + In this setting, this function will give you the "local bus" id for each loads that + have been changed by the agent / time series / voltage controlers / opponent / etc. + + .. warning:: /!\\\\ Do not alter / modify / change / override this implementation /!\\\\ + + .. seealso:: + The other related functions: + + - :func:`_BackendAction.get_loads_bus_global` + - :func:`_BackendAction.get_gens_bus_global` + - :func:`_BackendAction.get_lines_or_bus_global` + - :func:`_BackendAction.get_lines_ex_bus_global` + - :func:`_BackendAction.get_storages_bus_global` + + Examples + --------- + + Some examples are given in the documentation of :func:`_BackendAction.get_loads_bus_global` + """ + tmp_ = self.shunt_bus + return self._aux_to_global(tmp_, type(self).shunt_to_subid) - def _get_active_bus(self): + def _get_active_bus(self) -> None: + """ + .. warning:: /!\\\\ Internal, do not use unless you know what you are doing /!\\\\ + + """ self.activated_bus[:, :] = False - tmp = self.current_topo.values - 1 # TODO global to local ! + tmp = self.current_topo.values - 1 is_el_conn = tmp >= 0 self.activated_bus[self.big_topo_to_subid[is_el_conn], tmp[is_el_conn]] = True if type(self).shunts_data_available: @@ -593,11 +1421,13 @@ def _get_active_bus(self): tmp = self.current_shunt_bus.values - 1 self.activated_bus[type(self).shunt_to_subid[is_el_conn], tmp[is_el_conn]] = True - def update_state(self, powerline_disconnected): + def update_state(self, powerline_disconnected) -> None: """ .. warning:: /!\\\\ Internal, do not use unless you know what you are doing /!\\\\ + + This is handled by the environment ! - Update the internal state. Should be called after the cascading failures + Update the internal state. Should be called after the cascading failures. """ if (powerline_disconnected >= 0).any(): diff --git a/grid2op/Action/actionSpace.py b/grid2op/Action/actionSpace.py index 975b5e9d0..b8f870062 100644 --- a/grid2op/Action/actionSpace.py +++ b/grid2op/Action/actionSpace.py @@ -8,6 +8,7 @@ import warnings import copy +from typing import Dict, List, Any, Literal from grid2op.Action.baseAction import BaseAction from grid2op.Action.serializableActionSpace import SerializableActionSpace @@ -72,7 +73,23 @@ def __init__( self.legal_action = legal_action def __call__( - self, dict_: dict = None, check_legal: bool = False, env: "BaseEnv" = None + self, + dict_: Dict[Literal["injection", + "hazards", + "maintenance", + "set_line_status", + "change_line_status", + "set_bus", + "change_bus", + "redispatch", + "set_storage", + "curtail", + "raise_alarm", + "raise_alert"], Any] = None, + check_legal: bool = False, + env: "grid2op.Environment.BaseEnv" = None, + *, + injection=None, ) -> BaseAction: """ This utility allows you to build a valid action, with the proper sizes if you provide it with a valid @@ -116,10 +133,12 @@ def __call__( see :func:`Action.udpate`. """ - + # build the action res = self.actionClass() + # update the action res.update(dict_) + if check_legal: is_legal, reason = self._is_legal(res, env) if not is_legal: diff --git a/grid2op/Action/baseAction.py b/grid2op/Action/baseAction.py index 6f92ca139..6a66c0833 100644 --- a/grid2op/Action/baseAction.py +++ b/grid2op/Action/baseAction.py @@ -9,7 +9,13 @@ import copy import numpy as np import warnings -from typing import Tuple +from typing import Tuple, Dict, Literal, Any, List +try: + from typing import Self +except ImportError: + from typing_extensions import Self + +from packaging import version from grid2op.dtypes import dt_int, dt_bool, dt_float from grid2op.Exceptions import * @@ -73,9 +79,11 @@ class BaseAction(GridObjects): interpretation: - 0 -> don't change + - -1 -> disconnect the object. - 1 -> connect to bus 1 - 2 -> connect to bus 2 - - -1 -> disconnect the object. + - 3 -> connect to bus 3 (added in version 1.10.0) + - etc. (added in version 1.10.0) - the fifth element changes the buses to which the object is connected. It's a boolean vector interpreted as: @@ -581,7 +589,7 @@ def _aux_serialize_add_key_change(self, attr_nm, dict_key, res): res[dict_key] = tmp_ def _aux_serialize_add_key_set(self, attr_nm, dict_key, res): - tmp_ = [(int(id_), int(val)) for id_, val in enumerate(getattr(self, attr_nm)) if val != 0.] + tmp_ = [(int(id_), int(val)) for id_, val in enumerate(getattr(self, attr_nm)) if np.abs(val) >= 1e-7] if tmp_: res[dict_key] = tmp_ @@ -675,7 +683,7 @@ def as_serializable_dict(self) -> dict: res["redispatch"] = [ (int(id_), float(val)) for id_, val in enumerate(self._redispatch) - if val != 0.0 + if np.abs(val) >= 1e-7 ] if not res["redispatch"]: del res["redispatch"] @@ -684,7 +692,7 @@ def as_serializable_dict(self) -> dict: res["set_storage"] = [ (int(id_), float(val)) for id_, val in enumerate(self._storage_power) - if val != 0.0 + if np.abs(val) >= 1e-7 ] if not res["set_storage"]: del res["set_storage"] @@ -693,7 +701,7 @@ def as_serializable_dict(self) -> dict: res["curtail"] = [ (int(id_), float(val)) for id_, val in enumerate(self._curtail) - if val != -1 + if np.abs(val + 1.) >= 1e-7 ] if not res["curtail"]: del res["curtail"] @@ -756,7 +764,7 @@ def alarm_raised(self) -> np.ndarray: The indexes of the areas where the agent has raised an alarm. """ - return np.where(self._raise_alarm)[0] + return np.nonzero(self._raise_alarm)[0] def alert_raised(self) -> np.ndarray: """ @@ -770,41 +778,66 @@ def alert_raised(self) -> np.ndarray: The indexes of the lines where the agent has raised an alert. """ - return np.where(self._raise_alert)[0] + return np.nonzero(self._raise_alert)[0] + @classmethod + def _aux_process_old_compat(cls): + # this is really important, otherwise things from grid2op base types will be affected + cls.authorized_keys = copy.deepcopy(cls.authorized_keys) + cls.attr_list_vect = copy.deepcopy(cls.attr_list_vect) + + # deactivate storage + cls.set_no_storage() + if "set_storage" in cls.authorized_keys: + cls.authorized_keys.remove("set_storage") + if "_storage_power" in cls.attr_list_vect: + cls.attr_list_vect.remove("_storage_power") + cls.attr_list_set = set(cls.attr_list_vect) + + # remove the curtailment + if "curtail" in cls.authorized_keys: + cls.authorized_keys.remove("curtail") + if "_curtail" in cls.attr_list_vect: + cls.attr_list_vect.remove("_curtail") + + @classmethod + def _aux_process_n_busbar_per_sub(cls): + cls.authorized_keys = copy.deepcopy(cls.authorized_keys) + cls.attr_list_vect = copy.deepcopy(cls.attr_list_vect) + + if "change_bus" in cls.authorized_keys: + cls.authorized_keys.remove("change_bus") + if "_change_bus_vect" in cls.attr_list_vect: + cls.attr_list_vect.remove("_change_bus_vect") + @classmethod def process_grid2op_compat(cls): + super().process_grid2op_compat() + glop_ver = cls._get_grid2op_version_as_version_obj() + if cls.glop_version == cls.BEFORE_COMPAT_VERSION: # oldest version: no storage and no curtailment available - - # this is really important, otherwise things from grid2op base types will be affected - cls.authorized_keys = copy.deepcopy(cls.authorized_keys) - cls.attr_list_vect = copy.deepcopy(cls.attr_list_vect) - cls.attr_list_set = copy.deepcopy(cls.attr_list_set) - - # deactivate storage - cls.set_no_storage() - if "set_storage" in cls.authorized_keys: - cls.authorized_keys.remove("set_storage") - if "_storage_power" in cls.attr_list_vect: - cls.attr_list_vect.remove("_storage_power") - cls.attr_list_set = set(cls.attr_list_vect) - - # remove the curtailment - if "curtail" in cls.authorized_keys: - cls.authorized_keys.remove("curtail") - if "_curtail" in cls.attr_list_vect: - cls.attr_list_vect.remove("_curtail") - cls.attr_list_set = set(cls.attr_list_vect) - - if cls.glop_version < "1.6.0": + cls._aux_process_old_compat() + + if glop_ver < version.parse("1.6.0"): # this feature did not exist before. cls.dim_alarms = 0 - if cls.glop_version < "1.9.1": + if glop_ver < version.parse("1.9.1"): # this feature did not exist before. cls.dim_alerts = 0 + if (cls.n_busbar_per_sub >= 3) or (cls.n_busbar_per_sub == 1): + # only relevant for grid2op >= 1.10.0 + # remove "change_bus" if it's there more than 3 buses (no sense: where to change it ???) + # or if there are only one busbar (cannot change anything) + # 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) + def _reset_modified_flags(self): self._modif_inj = False self._modif_set_bus = False @@ -863,10 +896,10 @@ def _post_process_from_vect(self): self._modif_set_status = (self._set_line_status != 0).any() self._modif_change_status = (self._switch_line_status).any() self._modif_redispatch = ( - np.isfinite(self._redispatch) & (self._redispatch != 0.0) + np.isfinite(self._redispatch) & (np.abs(self._redispatch) >= 1e-7) ).any() - self._modif_storage = (self._storage_power != 0.0).any() - self._modif_curtailment = (self._curtail != -1.0).any() + self._modif_storage = (np.abs(self._storage_power) >= 1e-7).any() + self._modif_curtailment = (np.abs(self._curtail + 1.0) >= 1e-7).any() self._modif_alarm = self._raise_alarm.any() self._modif_alert = self._raise_alert.any() @@ -879,7 +912,7 @@ def _assign_attr_from_name(self, attr_nm, vect): super()._assign_attr_from_name(attr_nm, vect) self._post_process_from_vect() else: - if np.isfinite(vect).any() and (vect != 0.0).any(): + if np.isfinite(vect).any() and (np.abs(vect) >= 1e-7).any(): self._dict_inj[attr_nm] = vect def check_space_legit(self): @@ -1487,43 +1520,8 @@ def _assign_iadd_or_warn(self, attr_name, new_value): ) else: getattr(self, attr_name)[:] = new_value - - def __iadd__(self, other): - """ - Add an action to this one. - - Adding an action to myself is equivalent to perform myself, and then perform other (but at the - same step) - - Parameters - ---------- - other: :class:`BaseAction` - - Examples - -------- - - .. code-block:: python - - import grid2op - env_name = "l2rpn_case14_sandbox" # or any other name - env = grid2op.make(env_name) - - act1 = env.action_space() - act1.set_bus = ... # for example - print("before += :") - print(act1) - - act2 = env.action_space() - act2.redispatch = ... # for example - print(act2) - - act1 += act 2 - print("after += ") - print(act1) - - """ - - # deal with injections + + def _aux_iadd_inj(self, other): for el in self.attr_list_vect: if el in other._dict_inj: if el not in self._dict_inj: @@ -1538,9 +1536,10 @@ def __iadd__(self, other): warnings.warn( type(self).ERR_ACTION_CUT.format(el) ) - # redispatching + + def _aux_iadd_redisp(self, other): redispatching = other._redispatch - if (redispatching != 0.0).any(): + if (np.abs(redispatching) >= 1e-7).any(): if "_redispatch" not in self.attr_list_set: warnings.warn( type(self).ERR_ACTION_CUT.format("_redispatch") @@ -1548,21 +1547,10 @@ def __iadd__(self, other): else: ok_ind = np.isfinite(redispatching) self._redispatch[ok_ind] += redispatching[ok_ind] - - # storage - set_storage = other._storage_power - ok_ind = np.isfinite(set_storage) & (set_storage != 0.0).any() - if ok_ind.any(): - if "_storage_power" not in self.attr_list_set: - warnings.warn( - type(self).ERR_ACTION_CUT.format("_storage_power") - ) - else: - self._storage_power[ok_ind] += set_storage[ok_ind] - - # curtailment + + def _aux_iadd_curtail(self, other): curtailment = other._curtail - ok_ind = np.isfinite(curtailment) & (curtailment != -1.0) + ok_ind = np.isfinite(curtailment) & (np.abs(curtailment + 1.0) >= 1e-7) if ok_ind.any(): if "_curtail" not in self.attr_list_set: warnings.warn( @@ -1573,8 +1561,57 @@ def __iadd__(self, other): # the curtailment of rhs, only when rhs acts # on curtailment self._curtail[ok_ind] = curtailment[ok_ind] - - # set and change status + + def _aux_iadd_storage(self, other): + set_storage = other._storage_power + ok_ind = np.isfinite(set_storage) & (np.abs(set_storage) >= 1e-7).any() + if ok_ind.any(): + if "_storage_power" not in self.attr_list_set: + warnings.warn( + type(self).ERR_ACTION_CUT.format("_storage_power") + ) + else: + self._storage_power[ok_ind] += set_storage[ok_ind] + + def _aux_iadd_modif_flags(self, other): + self._modif_change_bus = self._modif_change_bus or other._modif_change_bus + self._modif_set_bus = self._modif_set_bus or other._modif_set_bus + self._modif_change_status = ( + self._modif_change_status or other._modif_change_status + ) + self._modif_set_status = self._modif_set_status or other._modif_set_status + self._modif_inj = self._modif_inj or other._modif_inj + self._modif_redispatch = self._modif_redispatch or other._modif_redispatch + self._modif_storage = self._modif_storage or other._modif_storage + self._modif_curtailment = self._modif_curtailment or other._modif_curtailment + self._modif_alarm = self._modif_alarm or other._modif_alarm + self._modif_alert = self._modif_alert or other._modif_alert + + def _aux_iadd_shunt(self, other): + if not type(other).shunts_data_available: + warnings.warn("Trying to add an action that does not support " + "shunt with an action that does.") + return + + val = other.shunt_p + ok_ind = np.isfinite(val) + shunt_p = 1.0 * self.shunt_p + shunt_p[ok_ind] = val[ok_ind] + self._assign_iadd_or_warn("shunt_p", shunt_p) + + val = other.shunt_q + ok_ind = np.isfinite(val) + shunt_q = 1.0 * self.shunt_q + shunt_q[ok_ind] = val[ok_ind] + self._assign_iadd_or_warn("shunt_q", shunt_q) + + val = other.shunt_bus + ok_ind = val != 0 + shunt_bus = 1 * self.shunt_bus + shunt_bus[ok_ind] = val[ok_ind] + self._assign_iadd_or_warn("shunt_bus", shunt_bus) + + def _aux_iadd_set_change_status(self, other): other_set = other._set_line_status other_change = other._switch_line_status me_set = 1 * self._set_line_status @@ -1603,8 +1640,8 @@ def __iadd__(self, other): self._assign_iadd_or_warn("_set_line_status", me_set) self._assign_iadd_or_warn("_switch_line_status", me_change) - - # set and change bus + + def _aux_iadd_set_change_bus(self, other): other_set = other._set_topo_vect other_change = other._change_bus_vect me_set = 1 * self._set_topo_vect @@ -1635,26 +1672,63 @@ def __iadd__(self, other): self._assign_iadd_or_warn("_set_topo_vect", me_set) self._assign_iadd_or_warn("_change_bus_vect", me_change) + + def __iadd__(self, other: Self): + """ + Add an action to this one. + + Adding an action to myself is equivalent to perform myself, and then perform other (but at the + same step) + + Parameters + ---------- + other: :class:`BaseAction` + + Examples + -------- + + .. code-block:: python + + import grid2op + env_name = "l2rpn_case14_sandbox" # or any other name + env = grid2op.make(env_name) + + act1 = env.action_space() + act1.set_bus = ... # for example + print("before += :") + print(act1) + + act2 = env.action_space() + act2.redispatch = ... # for example + print(act2) + + act1 += act 2 + print("after += ") + print(act1) + + """ + + # deal with injections + self._aux_iadd_inj(other) + + # redispatching + self._aux_iadd_redisp(other) + + # storage + self._aux_iadd_storage(other) + + # curtailment + self._aux_iadd_curtail(other) + + # set and change status + self._aux_iadd_set_change_status(other) + + # set and change bus + self._aux_iadd_set_change_bus(other) # shunts if type(self).shunts_data_available: - val = other.shunt_p - ok_ind = np.isfinite(val) - shunt_p = 1.0 * self.shunt_p - shunt_p[ok_ind] = val[ok_ind] - self._assign_iadd_or_warn("shunt_p", shunt_p) - - val = other.shunt_q - ok_ind = np.isfinite(val) - shunt_q = 1.0 * self.shunt_q - shunt_q[ok_ind] = val[ok_ind] - self._assign_iadd_or_warn("shunt_q", shunt_q) - - val = other.shunt_bus - ok_ind = val != 0 - shunt_bus = 1 * self.shunt_bus - shunt_bus[ok_ind] = val[ok_ind] - self._assign_iadd_or_warn("shunt_bus", shunt_bus) + self._aux_iadd_shunt(other) # alarm feature self._raise_alarm[other._raise_alarm] = True @@ -1664,19 +1738,7 @@ def __iadd__(self, other): # the modif flags - self._modif_change_bus = self._modif_change_bus or other._modif_change_bus - self._modif_set_bus = self._modif_set_bus or other._modif_set_bus - self._modif_change_status = ( - self._modif_change_status or other._modif_change_status - ) - self._modif_set_status = self._modif_set_status or other._modif_set_status - self._modif_inj = self._modif_inj or other._modif_inj - self._modif_redispatch = self._modif_redispatch or other._modif_redispatch - self._modif_storage = self._modif_storage or other._modif_storage - self._modif_curtailment = self._modif_curtailment or other._modif_curtailment - self._modif_alarm = self._modif_alarm or other._modif_alarm - self._modif_alert = self._modif_alert or other._modif_alert - + self._aux_iadd_modif_flags(other) return self def __add__(self, other) -> "BaseAction": @@ -1811,6 +1873,7 @@ def _digest_shunt(self, dict_): vect_self[:] = tmp elif isinstance(tmp, list): # expected a list: (id shunt, new bus) + cls = type(self) for (sh_id, new_bus) in tmp: if sh_id < 0: raise AmbiguousAction( @@ -1818,11 +1881,23 @@ def _digest_shunt(self, dict_): sh_id ) ) - if sh_id >= self.n_shunt: + if sh_id >= cls.n_shunt: raise AmbiguousAction( "Invalid shunt id {}. Shunt id should be less than the number " - "of shunt {}".format(sh_id, self.n_shunt) + "of shunt {}".format(sh_id, cls.n_shunt) ) + if key_n == "shunt_bus" or key_n == "set_bus": + if new_bus <= -2: + raise IllegalAction( + f"Cannot ask for a shunt bus <= -2, found {new_bus} for shunt id {sh_id}" + ) + elif new_bus > cls.n_busbar_per_sub: + raise IllegalAction( + f"Cannot ask for a shunt bus > {cls.n_busbar_per_sub} " + f"the maximum number of busbar per substations" + f", found {new_bus} for shunt id {sh_id}" + ) + vect_self[sh_id] = new_bus elif tmp is None: pass @@ -2103,13 +2178,17 @@ def update(self, dict_): - 0 -> don't change anything - +1 -> set to bus 1, - - +2 -> set to bus 2, etc. + - +2 -> set to bus 2 + - +3 -> set to bus 3 (grid2op >= 1.10.0) + - etc. - -1: You can use this method to disconnect an object by setting the value to -1. - "change_bus": (numpy bool vector or dictionary) will change the bus to which the object is connected. True will change it (eg switch it from bus 1 to bus 2 or from bus 2 to bus 1). NB this is only active if the system has only 2 buses per substation. + .. versionchanged:: 1.10.0 + This feature is deactivated if `act.n_busbar_per_sub >= 3` or `act.n_busbar_per_sub == 1` - "redispatch": the best use of this is to specify either the numpy array of the redispatch vector you want to apply (that should have the size of the number of generators on the grid) or to specify a list of @@ -2130,6 +2209,10 @@ def update(self, dict_): - If "change_bus" is True, then objects will be moved from one bus to another. If the object were on bus 1 then it will be moved on bus 2, and if it were on bus 2, it will be moved on bus 1. If the object is disconnected then the action is ambiguous, and calling it will throw an AmbiguousAction exception. + + - "curtail" : TODO + - "raise_alarm" : TODO + - "raise_alert": TODO **NB**: CHANGES: you can reconnect a powerline without specifying on each bus you reconnect it at both its ends. In that case the last known bus id for each its end is used. @@ -2152,12 +2235,15 @@ def update(self, dict_): be used to modify a :class:`grid2op.Backend.Backend`. In all the following examples, we suppose that a valid grid2op environment is created, for example with: + .. code-block:: python import grid2op + from grid2op.Action import BaseAction + env_name = "l2rpn_case14_sandbox" # create a simple environment # and make sure every type of action can be used. - env = grid2op.make(action_class=grid2op.Action.Action) + env = grid2op.make(env_name, action_class=BaseAction) *Example 1*: modify the load active values to set them all to 1. You can replace "load_p" by "load_q", "prod_p" or "prod_v" to change the load reactive value, the generator active setpoint or the generator @@ -2178,8 +2264,8 @@ def update(self, dict_): # there is a shortcut to do that: disconnect_powerline2 = env.disconnect_powerline(line_id=1) - *Example 3*: force the reconnection of the powerline of id 5 by connected it to bus 1 on its origin end and - bus 2 on its extremity end. + *Example 3*: force the reconnection of the powerline of id 5 by connected it to bus 1 on its origin side and + bus 2 on its extremity side. .. code-block:: python @@ -2340,7 +2426,7 @@ def _check_for_correct_modif_flags(self): "You illegally act on the powerline status (using change)" ) - if (self._redispatch != 0.0).any(): + if (np.abs(self._redispatch) >= 1e-7).any(): if not self._modif_redispatch: raise AmbiguousAction( "A action of type redispatch is performed while the appropriate flag " @@ -2351,7 +2437,7 @@ def _check_for_correct_modif_flags(self): if "redispatch" not in self.authorized_keys: raise IllegalAction("You illegally act on the redispatching") - if (self._storage_power != 0.0).any(): + if (np.abs(self._storage_power) >= 1e-7).any(): if not self._modif_storage: raise AmbiguousAction( "A action on the storage unit is performed while the appropriate flag " @@ -2362,7 +2448,7 @@ def _check_for_correct_modif_flags(self): if "set_storage" not in self.authorized_keys: raise IllegalAction("You illegally act on the storage unit") - if (self._curtail != -1.0).any(): + if (np.abs(self._curtail + 1.0) >= 1e-7).any(): if not self._modif_curtailment: raise AmbiguousAction( "A curtailment is performed while the action is not supposed to have done so. " @@ -2443,7 +2529,8 @@ def _check_for_ambiguity(self): """ # check that the correct flags are properly computed self._check_for_correct_modif_flags() - + cls = type(self) + if ( self._modif_change_status and self._modif_set_status @@ -2458,58 +2545,58 @@ def _check_for_ambiguity(self): # check size if self._modif_inj: if "load_p" in self._dict_inj: - if len(self._dict_inj["load_p"]) != self.n_load: + if len(self._dict_inj["load_p"]) != cls.n_load: raise InvalidNumberOfLoads( "This action acts on {} loads while there are {} " "in the _grid".format( - len(self._dict_inj["load_p"]), self.n_load + len(self._dict_inj["load_p"]), cls.n_load ) ) if "load_q" in self._dict_inj: - if len(self._dict_inj["load_q"]) != self.n_load: + if len(self._dict_inj["load_q"]) != cls.n_load: raise InvalidNumberOfLoads( "This action acts on {} loads while there are {} in " - "the _grid".format(len(self._dict_inj["load_q"]), self.n_load) + "the _grid".format(len(self._dict_inj["load_q"]), cls.n_load) ) if "prod_p" in self._dict_inj: - if len(self._dict_inj["prod_p"]) != self.n_gen: + if len(self._dict_inj["prod_p"]) != cls.n_gen: raise InvalidNumberOfGenerators( "This action acts on {} generators while there are {} in " - "the _grid".format(len(self._dict_inj["prod_p"]), self.n_gen) + "the _grid".format(len(self._dict_inj["prod_p"]), cls.n_gen) ) if "prod_v" in self._dict_inj: - if len(self._dict_inj["prod_v"]) != self.n_gen: + if len(self._dict_inj["prod_v"]) != cls.n_gen: raise InvalidNumberOfGenerators( "This action acts on {} generators while there are {} in " - "the _grid".format(len(self._dict_inj["prod_v"]), self.n_gen) + "the _grid".format(len(self._dict_inj["prod_v"]), cls.n_gen) ) - if len(self._switch_line_status) != self.n_line: + if len(self._switch_line_status) != cls.n_line: raise InvalidNumberOfLines( "This action acts on {} lines while there are {} in " - "the _grid".format(len(self._switch_line_status), self.n_line) + "the _grid".format(len(self._switch_line_status), cls.n_line) ) - if len(self._set_topo_vect) != self.dim_topo: + if len(self._set_topo_vect) != cls.dim_topo: raise InvalidNumberOfObjectEnds( "This action acts on {} ends of object while there are {} " - "in the _grid".format(len(self._set_topo_vect), self.dim_topo) + "in the _grid".format(len(self._set_topo_vect), cls.dim_topo) ) - if len(self._change_bus_vect) != self.dim_topo: + if len(self._change_bus_vect) != cls.dim_topo: raise InvalidNumberOfObjectEnds( "This action acts on {} ends of object while there are {} " - "in the _grid".format(len(self._change_bus_vect), self.dim_topo) + "in the _grid".format(len(self._change_bus_vect), cls.dim_topo) ) - if len(self._redispatch) != self.n_gen: + if len(self._redispatch) != cls.n_gen: raise InvalidNumberOfGenerators( "This action acts on {} generators (redispatching= while " - "there are {} in the grid".format(len(self._redispatch), self.n_gen) + "there are {} in the grid".format(len(self._redispatch), cls.n_gen) ) # redispatching specific check if self._modif_redispatch: - if "redispatch" not in self.authorized_keys: + if "redispatch" not in cls.authorized_keys: raise AmbiguousAction( 'Action of type "redispatch" are not supported by this action type' ) @@ -2519,17 +2606,17 @@ def _check_for_ambiguity(self): "environment. Please set up the proper costs for generator" ) - if (self._redispatch[~self.gen_redispatchable] != 0.0).any(): + if (np.abs(self._redispatch[~cls.gen_redispatchable]) >= 1e-7).any(): raise InvalidRedispatching( "Trying to apply a redispatching action on a non redispatchable generator" ) if self._single_act: - if (self._redispatch > self.gen_max_ramp_up).any(): + if (self._redispatch > cls.gen_max_ramp_up).any(): raise InvalidRedispatching( "Some redispatching amount are above the maximum ramp up" ) - if (-self._redispatch > self.gen_max_ramp_down).any(): + if (-self._redispatch > cls.gen_max_ramp_down).any(): raise InvalidRedispatching( "Some redispatching amount are bellow the maximum ramp down" ) @@ -2538,12 +2625,12 @@ def _check_for_ambiguity(self): new_p = self._dict_inj["prod_p"] tmp_p = new_p + self._redispatch indx_ok = np.isfinite(new_p) - if (tmp_p[indx_ok] > self.gen_pmax[indx_ok]).any(): + if (tmp_p[indx_ok] > cls.gen_pmax[indx_ok]).any(): raise InvalidRedispatching( "Some redispatching amount, cumulated with the production setpoint, " "are above pmax for some generator." ) - if (tmp_p[indx_ok] < self.gen_pmin[indx_ok]).any(): + if (tmp_p[indx_ok] < cls.gen_pmin[indx_ok]).any(): raise InvalidRedispatching( "Some redispatching amount, cumulated with the production setpoint, " "are below pmin for some generator." @@ -2572,7 +2659,7 @@ def _check_for_ambiguity(self): "1 (assign this object to bus one) or 2 (assign this object to bus" "2). A negative number has been found." ) - if self._modif_set_bus and (self._set_topo_vect > 2).any(): + if self._modif_set_bus and (self._set_topo_vect > cls.n_busbar_per_sub).any(): raise InvalidBusStatus( "Invalid set_bus. Buses should be either -1 (disconnect), 0 (change nothing)," "1 (assign this object to bus one) or 2 (assign this object to bus" @@ -2598,62 +2685,62 @@ def _check_for_ambiguity(self): ) if self._modif_set_bus: - disco_or = self._set_topo_vect[self.line_or_pos_topo_vect] == -1 - if (self._set_topo_vect[self.line_ex_pos_topo_vect][disco_or] > 0).any(): + disco_or = self._set_topo_vect[cls.line_or_pos_topo_vect] == -1 + if (self._set_topo_vect[cls.line_ex_pos_topo_vect][disco_or] > 0).any(): raise InvalidLineStatus( - "A powerline is connected (set to a bus at extremity end) and " - "disconnected (set to bus -1 at origin end)" + "A powerline is connected (set to a bus at extremity side) and " + "disconnected (set to bus -1 at origin side)" ) - disco_ex = self._set_topo_vect[self.line_ex_pos_topo_vect] == -1 - if (self._set_topo_vect[self.line_or_pos_topo_vect][disco_ex] > 0).any(): + disco_ex = self._set_topo_vect[cls.line_ex_pos_topo_vect] == -1 + if (self._set_topo_vect[cls.line_or_pos_topo_vect][disco_ex] > 0).any(): raise InvalidLineStatus( - "A powerline is connected (set to a bus at origin end) and " - "disconnected (set to bus -1 at extremity end)" + "A powerline is connected (set to a bus at origin side) and " + "disconnected (set to bus -1 at extremity side)" ) # if i disconnected of a line, but i modify also the bus where it's connected if self._modif_set_bus or self._modif_change_bus: idx = self._set_line_status == -1 - id_disc = np.where(idx)[0] + id_disc = np.nonzero(idx)[0] idx2 = self._set_line_status == 1 - id_reco = np.where(idx2)[0] + id_reco = np.nonzero(idx2)[0] if self._modif_set_bus: - if "set_bus" not in self.authorized_keys: + if "set_bus" not in cls.authorized_keys: raise AmbiguousAction( 'Action of type "set_bus" are not supported by this action type' ) if ( - self._set_topo_vect[self.line_or_pos_topo_vect[id_disc]] > 0 - ).any() or (self._set_topo_vect[self.line_ex_pos_topo_vect[id_disc]] > 0).any(): + self._set_topo_vect[cls.line_or_pos_topo_vect[id_disc]] > 0 + ).any() or (self._set_topo_vect[cls.line_ex_pos_topo_vect[id_disc]] > 0).any(): raise InvalidLineStatus( "You ask to disconnect a powerline but also to connect it " "to a certain bus." ) if ( - self._set_topo_vect[self.line_or_pos_topo_vect[id_reco]] == -1 - ).any() or (self._set_topo_vect[self.line_ex_pos_topo_vect[id_reco]] == -1).any(): + self._set_topo_vect[cls.line_or_pos_topo_vect[id_reco]] == -1 + ).any() or (self._set_topo_vect[cls.line_ex_pos_topo_vect[id_reco]] == -1).any(): raise InvalidLineStatus( "You ask to reconnect a powerline but also to disconnect it " "from a certain bus." ) if self._modif_change_bus: - if "change_bus" not in self.authorized_keys: + if "change_bus" not in cls.authorized_keys: raise AmbiguousAction( 'Action of type "change_bus" are not supported by this action type' ) if ( - self._change_bus_vect[self.line_or_pos_topo_vect[id_disc]] > 0 - ).any() or (self._change_bus_vect[self.line_ex_pos_topo_vect[id_disc]] > 0).any(): + self._change_bus_vect[cls.line_or_pos_topo_vect[id_disc]] > 0 + ).any() or (self._change_bus_vect[cls.line_ex_pos_topo_vect[id_disc]] > 0).any(): raise InvalidLineStatus( "You ask to disconnect a powerline but also to change its bus." ) if ( self._change_bus_vect[ - self.line_or_pos_topo_vect[self._set_line_status == 1] + cls.line_or_pos_topo_vect[self._set_line_status == 1] ] ).any(): raise InvalidLineStatus( @@ -2662,7 +2749,7 @@ def _check_for_ambiguity(self): ) if ( self._change_bus_vect[ - self.line_ex_pos_topo_vect[self._set_line_status == 1] + cls.line_ex_pos_topo_vect[self._set_line_status == 1] ] ).any(): raise InvalidLineStatus( @@ -2670,21 +2757,21 @@ def _check_for_ambiguity(self): "which it is connected. This is ambiguous. You must *set* this bus instead." ) - if type(self).shunts_data_available: - if self.shunt_p.shape[0] != self.n_shunt: + if cls.shunts_data_available: + if self.shunt_p.shape[0] != cls.n_shunt: raise IncorrectNumberOfElements( "Incorrect number of shunt (for shunt_p) in your action." ) - if self.shunt_q.shape[0] != self.n_shunt: + if self.shunt_q.shape[0] != cls.n_shunt: raise IncorrectNumberOfElements( "Incorrect number of shunt (for shunt_q) in your action." ) - if self.shunt_bus.shape[0] != self.n_shunt: + if self.shunt_bus.shape[0] != cls.n_shunt: raise IncorrectNumberOfElements( "Incorrect number of shunt (for shunt_bus) in your action." ) - if self.n_shunt > 0: - if np.max(self.shunt_bus) > 2: + if cls.n_shunt > 0: + if np.max(self.shunt_bus) > cls.n_busbar_per_sub: raise AmbiguousAction( "Some shunt is connected to a bus greater than 2" ) @@ -2709,10 +2796,10 @@ def _check_for_ambiguity(self): ) if self._modif_alarm: - if self._raise_alarm.shape[0] != self.dim_alarms: + if self._raise_alarm.shape[0] != cls.dim_alarms: raise AmbiguousAction( f"Wrong number of alarm raised: {self._raise_alarm.shape[0]} raised, expecting " - f"{self.dim_alarms}" + f"{cls.dim_alarms}" ) else: if self._raise_alarm.any(): @@ -2722,10 +2809,10 @@ def _check_for_ambiguity(self): ) if self._modif_alert: - if self._raise_alert.shape[0] != self.dim_alerts: + if self._raise_alert.shape[0] != cls.dim_alerts: raise AmbiguousActionRaiseAlert( f"Wrong number of alert raised: {self._raise_alert.shape[0]} raised, expecting " - f"{self.dim_alerts}" + f"{cls.dim_alerts}" ) else: if self._raise_alert.any(): @@ -2736,75 +2823,77 @@ def _check_for_ambiguity(self): def _is_storage_ambiguous(self): """check if storage actions are ambiguous""" + cls = type(self) if self._modif_storage: - if "set_storage" not in self.authorized_keys: + if "set_storage" not in cls.authorized_keys: raise AmbiguousAction( 'Action of type "set_storage" are not supported by this action type' ) - if self.n_storage == 0: + if cls.n_storage == 0: raise InvalidStorage( "Attempt to modify a storage unit while there is none on the grid" ) - if self._storage_power.shape[0] != self.n_storage: + if self._storage_power.shape[0] != cls.n_storage: raise InvalidStorage( "self._storage_power.shape[0] != self.n_storage: wrong number of storage " "units affected" ) - if (self._storage_power < -self.storage_max_p_prod).any(): - where_bug = np.where(self._storage_power < -self.storage_max_p_prod)[0] + if (self._storage_power < -cls.storage_max_p_prod).any(): + where_bug = np.nonzero(self._storage_power < -cls.storage_max_p_prod)[0] raise InvalidStorage( f"you asked a storage unit to absorb more than what it can: " f"self._storage_power[{where_bug}] < -self.storage_max_p_prod[{where_bug}]." ) - if (self._storage_power > self.storage_max_p_absorb).any(): - where_bug = np.where(self._storage_power > self.storage_max_p_absorb)[0] + if (self._storage_power > cls.storage_max_p_absorb).any(): + where_bug = np.nonzero(self._storage_power > cls.storage_max_p_absorb)[0] raise InvalidStorage( f"you asked a storage unit to produce more than what it can: " f"self._storage_power[{where_bug}] > self.storage_max_p_absorb[{where_bug}]." ) - if "_storage_power" not in self.attr_list_set: - if (self._set_topo_vect[self.storage_pos_topo_vect] > 0).any(): + if "_storage_power" not in cls.attr_list_set: + if (self._set_topo_vect[cls.storage_pos_topo_vect] > 0).any(): raise InvalidStorage("Attempt to modify bus (set) of a storage unit") - if (self._change_bus_vect[self.storage_pos_topo_vect]).any(): + if (self._change_bus_vect[cls.storage_pos_topo_vect]).any(): raise InvalidStorage("Attempt to modify bus (change) of a storage unit") def _is_curtailment_ambiguous(self): """check if curtailment action is ambiguous""" + cls = type(self) if self._modif_curtailment: - if "curtail" not in self.authorized_keys: + if "curtail" not in cls.authorized_keys: raise AmbiguousAction( 'Action of type "curtail" are not supported by this action type' ) - if not self.redispatching_unit_commitment_availble: + if not cls.redispatching_unit_commitment_availble: raise UnitCommitorRedispachingNotAvailable( "Impossible to use a redispatching action in this " "environment. Please set up the proper costs for generator. " "This also means curtailment feature is not available." ) - if self._curtail.shape[0] != self.n_gen: + if self._curtail.shape[0] != cls.n_gen: raise InvalidCurtailment( "self._curtail.shape[0] != self.n_gen: wrong number of generator " "units affected" ) - if ((self._curtail < 0.0) & (self._curtail != -1.0)).any(): - where_bug = np.where((self._curtail < 0.0) & (self._curtail != -1.0))[0] + if ((self._curtail < 0.0) & (np.abs(self._curtail + 1.0) >= 1e-7)).any(): + where_bug = np.nonzero((self._curtail < 0.0) & (np.abs(self._curtail + 1.0) >= 1e-7))[0] raise InvalidCurtailment( f"you asked to perform a negative curtailment: " f"self._curtail[{where_bug}] < 0. " f"Curtailment should be a real number between 0.0 and 1.0" ) if (self._curtail > 1.0).any(): - where_bug = np.where(self._curtail > 1.0)[0] + where_bug = np.nonzero(self._curtail > 1.0)[0] raise InvalidCurtailment( f"you asked a storage unit to produce more than what it can: " f"self._curtail[{where_bug}] > 1. " f"Curtailment should be a real number between 0.0 and 1.0" ) - if (self._curtail[~self.gen_renewable] != -1.0).any(): + if (np.abs(self._curtail[~cls.gen_renewable] +1.0) >= 1e-7).any(): raise InvalidCurtailment( "Trying to apply a curtailment on a non renewable generator" ) @@ -2816,41 +2905,55 @@ def _ignore_topo_action_if_disconnection(self, sel_): self._set_topo_vect[np.array(self.line_ex_pos_topo_vect[sel_])] = 0 self._change_bus_vect[np.array(self.line_ex_pos_topo_vect[sel_])] = False - def _obj_caract_from_topo_id(self, id_): - obj_id = None - objt_type = None - array_subid = None - for l_id, id_in_topo in enumerate(self.load_pos_topo_vect): + def _aux_obj_caract(self, id_, with_name, xxx_pos_topo_vect, objt_type, xxx_subid, name_xxx): + for l_id, id_in_topo in enumerate(xxx_pos_topo_vect): if id_in_topo == id_: obj_id = l_id - objt_type = "load" - array_subid = self.load_to_subid - if obj_id is None: - for l_id, id_in_topo in enumerate(self.gen_pos_topo_vect): - if id_in_topo == id_: - obj_id = l_id - objt_type = "generator" - array_subid = self.gen_to_subid - if obj_id is None: - for l_id, id_in_topo in enumerate(self.line_or_pos_topo_vect): - if id_in_topo == id_: - obj_id = l_id - objt_type = self._line_or_str - array_subid = self.line_or_to_subid - if obj_id is None: - for l_id, id_in_topo in enumerate(self.line_ex_pos_topo_vect): - if id_in_topo == id_: - obj_id = l_id - objt_type = self._line_ex_str - array_subid = self.line_ex_to_subid - if obj_id is None: - for l_id, id_in_topo in enumerate(self.storage_pos_topo_vect): - if id_in_topo == id_: - obj_id = l_id - objt_type = "storage" - array_subid = self.storage_to_subid - substation_id = array_subid[obj_id] - return obj_id, objt_type, substation_id + obj_name = name_xxx[l_id] + substation_id = xxx_subid[obj_id] + if not with_name: + return obj_id, objt_type, substation_id + return obj_id, objt_type, substation_id, obj_name + return None + + def _aux_obj_caract_from_topo_id_load(self, cls, id_, with_name): + return self._aux_obj_caract(id_, with_name, cls.load_pos_topo_vect, "load", cls.load_to_subid, cls.name_load) + + def _aux_obj_caract_from_topo_id_gen(self, cls, id_, with_name): + return self._aux_obj_caract(id_, with_name, cls.gen_pos_topo_vect, + "generator", cls.gen_to_subid, cls.name_gen) + + def _aux_obj_caract_from_topo_id_lor(self, cls, id_, with_name): + return self._aux_obj_caract(id_, with_name, cls.line_or_pos_topo_vect, + self._line_or_str, cls.line_or_to_subid, cls.name_line) + + def _aux_obj_caract_from_topo_id_lex(self, cls, id_, with_name): + return self._aux_obj_caract(id_, with_name, cls.line_ex_pos_topo_vect, + self._line_ex_str, cls.line_ex_to_subid, cls.name_line) + + def _aux_obj_caract_from_topo_storage(self, cls, id_, with_name): + return self._aux_obj_caract(id_, with_name, cls.storage_pos_topo_vect, + "storage", cls.storage_to_subid, cls.name_storage) + + def _obj_caract_from_topo_id(self, id_, with_name=False): + # TODO refactor this with gridobj.topo_vect_element + cls = type(self) + tmp = self._aux_obj_caract_from_topo_id_load(cls, id_, with_name) + if tmp is not None: + return tmp + tmp = self._aux_obj_caract_from_topo_id_gen(cls, id_, with_name) + if tmp is not None: + return tmp + tmp = self._aux_obj_caract_from_topo_id_lor(cls, id_, with_name) + if tmp is not None: + return tmp + tmp = self._aux_obj_caract_from_topo_id_lex(cls, id_, with_name) + if tmp is not None: + return tmp + tmp = self._aux_obj_caract_from_topo_storage(cls, id_, with_name) + if tmp is not None: + return tmp + raise Grid2OpException(f"Unknown element in topovect with id {id_}") def __str__(self) -> str: """ @@ -2889,7 +2992,7 @@ def __str__(self) -> str: "\t - Modify the generators with redispatching in the following way:" ) for gen_idx in range(self.n_gen): - if self._redispatch[gen_idx] != 0.0: + if np.abs(self._redispatch[gen_idx]) >= 1e-7: gen_name = self.name_gen[gen_idx] r_amount = self._redispatch[gen_idx] res.append( @@ -2905,7 +3008,7 @@ def __str__(self) -> str: res.append("\t - Modify the storage units in the following way:") for stor_idx in range(self.n_storage): amount_ = self._storage_power[stor_idx] - if np.isfinite(amount_) and amount_ != 0.0: + if np.isfinite(amount_) and np.abs(amount_) >= 1e-7: name_ = self.name_storage[stor_idx] res.append( '\t \t - Ask unit "{}" to {} {:.2f} MW (setpoint: {:.2f} MW)' @@ -2924,7 +3027,7 @@ def __str__(self) -> str: res.append("\t - Perform the following curtailment:") for gen_idx in range(self.n_gen): amount_ = self._curtail[gen_idx] - if np.isfinite(amount_) and amount_ != -1.0: + if np.isfinite(amount_) and np.abs(amount_ + 1.0) >= 1e-7: name_ = self.name_gen[gen_idx] res.append( '\t \t - Limit unit "{}" to {:.1f}% of its Pmax (setpoint: {:.3f})' @@ -3009,7 +3112,7 @@ def __str__(self) -> str: if my_cls.dim_alarms > 0: if self._modif_alarm: li_area = np.array(my_cls.alarms_area_names)[ - np.where(self._raise_alarm)[0] + np.nonzero(self._raise_alarm)[0] ] if len(li_area) == 1: area_str = ": " + li_area[0] @@ -3021,7 +3124,7 @@ def __str__(self) -> str: if my_cls.dim_alerts > 0: if self._modif_alert: - i_alert = np.where(self._raise_alert)[0] + i_alert = np.nonzero(self._raise_alert)[0] li_line = np.array(my_cls.alertable_line_names)[i_alert] if len(li_line) == 1: line_str = f": {i_alert[0]} (on line {li_line[0]})" @@ -3067,7 +3170,7 @@ def impact_on_objects(self) -> dict: force_line_status["reconnections"]["count"] = ( self._set_line_status == 1 ).sum() - force_line_status["reconnections"]["powerlines"] = np.where( + force_line_status["reconnections"]["powerlines"] = np.nonzero( self._set_line_status == 1 )[0] @@ -3077,7 +3180,7 @@ def impact_on_objects(self) -> dict: force_line_status["disconnections"]["count"] = ( self._set_line_status == -1 ).sum() - force_line_status["disconnections"]["powerlines"] = np.where( + force_line_status["disconnections"]["powerlines"] = np.nonzero( self._set_line_status == -1 )[0] @@ -3087,7 +3190,7 @@ def impact_on_objects(self) -> dict: switch_line_status["changed"] = True has_impact = True switch_line_status["count"] = self._switch_line_status.sum() - switch_line_status["powerlines"] = np.where(self._switch_line_status)[0] + switch_line_status["powerlines"] = np.nonzero(self._switch_line_status)[0] topology = { "changed": False, @@ -3145,9 +3248,9 @@ def impact_on_objects(self) -> dict: # handle redispatching redispatch = {"changed": False, "generators": []} - if (self._redispatch != 0.0).any(): + if (np.abs(self._redispatch) >= 1e-7).any(): for gen_idx in range(self.n_gen): - if self._redispatch[gen_idx] != 0.0: + if np.abs(self._redispatch[gen_idx]) >= 1e-7: gen_name = self.name_gen[gen_idx] r_amount = self._redispatch[gen_idx] redispatch["generators"].append( @@ -3177,7 +3280,7 @@ def impact_on_objects(self) -> dict: if self._modif_curtailment: for gen_idx in range(self.n_gen): tmp = self._curtail[gen_idx] - if np.isfinite(tmp) and tmp != -1: + if np.isfinite(tmp) and np.abs(tmp + 1.) >= 1e-7: name_ = self.name_gen[gen_idx] new_max = tmp curtailment["limit"].append( @@ -3201,7 +3304,85 @@ def impact_on_objects(self) -> dict: "curtailment": curtailment, } - def as_dict(self) -> dict: + def _aux_as_dict_set_line(self, res): + res["set_line_status"] = {} + res["set_line_status"]["nb_connected"] = (self._set_line_status == 1).sum() + res["set_line_status"]["nb_disconnected"] = ( + self._set_line_status == -1 + ).sum() + res["set_line_status"]["connected_id"] = np.nonzero( + self._set_line_status == 1 + )[0] + res["set_line_status"]["disconnected_id"] = np.nonzero( + self._set_line_status == -1 + )[0] + + def _aux_as_dict_change_line(self, res): + res["change_line_status"] = {} + res["change_line_status"]["nb_changed"] = self._switch_line_status.sum() + res["change_line_status"]["changed_id"] = np.nonzero( + self._switch_line_status + )[0] + + def _aux_as_dict_change_bus(self, res): + res["change_bus_vect"] = {} + res["change_bus_vect"]["nb_modif_objects"] = self._change_bus_vect.sum() + all_subs = set() + for id_, k in enumerate(self._change_bus_vect): + if k: + obj_id, objt_type, substation_id, nm_ = self._obj_caract_from_topo_id( + id_, with_name=True + ) + sub_id = "{}".format(substation_id) + if not sub_id in res["change_bus_vect"]: + res["change_bus_vect"][sub_id] = {} + res["change_bus_vect"][sub_id][nm_] = { + "type": objt_type, + "id": obj_id, + } + all_subs.add(sub_id) + + res["change_bus_vect"]["nb_modif_subs"] = len(all_subs) + res["change_bus_vect"]["modif_subs_id"] = sorted(all_subs) + + def _aux_as_dict_set_bus(self, res): + res["set_bus_vect"] = {} + res["set_bus_vect"]["nb_modif_objects"] = (self._set_topo_vect != 0).sum() + all_subs = set() + for id_, k in enumerate(self._set_topo_vect): + if k != 0: + obj_id, objt_type, substation_id, nm_ = self._obj_caract_from_topo_id( + id_, with_name=True + ) + sub_id = "{}".format(substation_id) + if not sub_id in res["set_bus_vect"]: + res["set_bus_vect"][sub_id] = {} + res["set_bus_vect"][sub_id][nm_] = { + "type": objt_type, + "id": obj_id, + "new_bus": k, + } + all_subs.add(sub_id) + + res["set_bus_vect"]["nb_modif_subs"] = len(all_subs) + res["set_bus_vect"]["modif_subs_id"] = sorted(all_subs) + + def _aux_as_dict_shunt(self, res): + tmp = {} + if np.any(np.isfinite(self.shunt_p)): + tmp["shunt_p"] = 1.0 * self.shunt_p + if np.any(np.isfinite(self.shunt_q)): + tmp["shunt_q"] = 1.0 * self.shunt_q + if np.any(self.shunt_bus != 0): + tmp["shunt_bus"] = 1.0 * self.shunt_bus + if tmp: + res["shunt"] = tmp + + def as_dict(self) -> Dict[Literal["load_p", "load_q", "prod_p", "prod_v", + "change_line_status", "set_line_status", + "change_bus_vect", "set_bus_vect", + "redispatch", "storage_power", "curtailment"], + Any]: """ Represent an action "as a" dictionary. This dictionary is useful to further inspect on which elements the actions had an impact. It is not recommended to use it as a way to serialize actions. The "do nothing" @@ -3256,7 +3437,8 @@ def as_dict(self) -> dict: dispatchable one) the amount of power redispatched in this action. * `storage_power`: the setpoint for production / consumption for all storage units * `curtailment`: the curtailment performed on all generator - + * `shunt` : + Returns ------- res: ``dict`` @@ -3272,80 +3454,29 @@ def as_dict(self) -> dict: # handles actions on force line status if (self._set_line_status != 0).any(): - res["set_line_status"] = {} - res["set_line_status"]["nb_connected"] = (self._set_line_status == 1).sum() - res["set_line_status"]["nb_disconnected"] = ( - self._set_line_status == -1 - ).sum() - res["set_line_status"]["connected_id"] = np.where( - self._set_line_status == 1 - )[0] - res["set_line_status"]["disconnected_id"] = np.where( - self._set_line_status == -1 - )[0] + self._aux_as_dict_set_line(res) # handles action on swtich line status if self._switch_line_status.sum(): - res["change_line_status"] = {} - res["change_line_status"]["nb_changed"] = self._switch_line_status.sum() - res["change_line_status"]["changed_id"] = np.where( - self._switch_line_status - )[0] + self._aux_as_dict_change_line(res) # handles topology change if (self._change_bus_vect).any(): - res["change_bus_vect"] = {} - res["change_bus_vect"]["nb_modif_objects"] = self._change_bus_vect.sum() - all_subs = set() - for id_, k in enumerate(self._change_bus_vect): - if k: - obj_id, objt_type, substation_id = self._obj_caract_from_topo_id( - id_ - ) - sub_id = "{}".format(substation_id) - if not sub_id in res["change_bus_vect"]: - res["change_bus_vect"][sub_id] = {} - res["change_bus_vect"][sub_id]["{}_{}".format(objt_type, obj_id)] = { - "type": objt_type, - "id": obj_id, - } - all_subs.add(sub_id) - - res["change_bus_vect"]["nb_modif_subs"] = len(all_subs) - res["change_bus_vect"]["modif_subs_id"] = sorted(all_subs) + self._aux_as_dict_change_bus(res) # handles topology set if (self._set_topo_vect!= 0).any(): - res["set_bus_vect"] = {} - res["set_bus_vect"]["nb_modif_objects"] = (self._set_topo_vect != 0).sum() - all_subs = set() - for id_, k in enumerate(self._set_topo_vect): - if k != 0: - obj_id, objt_type, substation_id = self._obj_caract_from_topo_id( - id_ - ) - sub_id = "{}".format(substation_id) - if not sub_id in res["set_bus_vect"]: - res["set_bus_vect"][sub_id] = {} - res["set_bus_vect"][sub_id]["{}_{}".format(objt_type, obj_id)] = { - "type": objt_type, - "id": obj_id, - "new_bus": k, - } - all_subs.add(sub_id) - - res["set_bus_vect"]["nb_modif_subs"] = len(all_subs) - res["set_bus_vect"]["modif_subs_id"] = sorted(all_subs) + self._aux_as_dict_set_bus(res) if self._hazards.any(): - res["hazards"] = np.where(self._hazards)[0] + res["hazards"] = np.nonzero(self._hazards)[0] res["nb_hazards"] = self._hazards.sum() if self._maintenance.any(): - res["maintenance"] = np.where(self._maintenance)[0] + res["maintenance"] = np.nonzero(self._maintenance)[0] res["nb_maintenance"] = self._maintenance.sum() - if (self._redispatch != 0.0).any(): + if (np.abs(self._redispatch) >= 1e-7).any(): res["redispatch"] = 1.0 * self._redispatch if self._modif_storage: @@ -3353,7 +3484,9 @@ def as_dict(self) -> dict: if self._modif_curtailment: res["curtailment"] = 1.0 * self._curtail - + + if type(self).shunts_data_available: + self._aux_as_dict_shunt(res) return res def get_types(self) -> Tuple[bool, bool, bool, bool, bool, bool, bool]: @@ -3410,7 +3543,7 @@ def get_types(self) -> Tuple[bool, bool, bool, bool, bool, bool, bool]: lines_impacted, subs_impacted = self.get_topological_impact() topology = subs_impacted.any() line = lines_impacted.any() - redispatching = (self._redispatch != 0.0).any() + redispatching = (np.abs(self._redispatch) >= 1e-7).any() storage = self._modif_storage curtailment = self._modif_curtailment return injection, voltage, topology, line, redispatching, storage, curtailment @@ -3492,9 +3625,10 @@ def _aux_effect_on_storage(self, storage_id): return res def _aux_effect_on_substation(self, substation_id): - if substation_id >= self.n_sub: + cls = type(self) + if substation_id >= cls.n_sub: raise Grid2OpException( - f"There are only {self.n_sub} substations on the grid. " + f"There are only {cls.n_sub} substations on the grid. " f"Cannot check impact on " f"`substation_id={substation_id}`" ) @@ -3502,8 +3636,8 @@ def _aux_effect_on_substation(self, substation_id): raise Grid2OpException(f"`substation_id` should be positive.") res = {} - beg_ = int(self.sub_info[:substation_id].sum()) - end_ = int(beg_ + self.sub_info[substation_id]) + beg_ = int(cls.sub_info[:substation_id].sum()) + end_ = int(beg_ + cls.sub_info[substation_id]) res["change_bus"] = self._change_bus_vect[beg_:end_] res["set_bus"] = self._set_topo_vect[beg_:end_] return res @@ -3570,8 +3704,8 @@ def effect_on( - if a powerline is inspected then the keys are: - - "change_bus_or": whether or not the origin end will be moved from one bus to another - - "change_bus_ex": whether or not the extremity end will be moved from one bus to another + - "change_bus_or": whether or not the origin side will be moved from one bus to another + - "change_bus_ex": whether or not the extremity side will be moved from one bus to another - "set_bus_or": the new bus where the origin will be moved - "set_bus_ex": the new bus where the extremity will be moved - "set_line_status": the new status of the power line @@ -3674,10 +3808,11 @@ def get_storage_modif(self) -> Tuple[np.ndarray, np.ndarray, np.ndarray]: New bus of the storage units, affected with "change_bus" command """ + cls = type(self) storage_power = 1.0 * self._storage_power - storage_set_bus = 1 * self._set_topo_vect[self.storage_pos_topo_vect] + storage_set_bus = 1 * self._set_topo_vect[cls.storage_pos_topo_vect] storage_change_bus = copy.deepcopy( - self._change_bus_vect[self.storage_pos_topo_vect] + self._change_bus_vect[cls.storage_pos_topo_vect] ) return storage_power, storage_set_bus, storage_change_bus @@ -3696,14 +3831,15 @@ def get_load_modif(self) -> Tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray load_change_bus: ``np.ndarray`` New bus of the loads, affected with "change_bus" command """ - load_p = np.full(self.n_load, fill_value=np.NaN, dtype=dt_float) + cls = type(self) + load_p = np.full(cls.n_load, fill_value=np.NaN, dtype=dt_float) if "load_p" in self._dict_inj: load_p[:] = self._dict_inj["load_p"] load_q = 1.0 * load_p if "load_q" in self._dict_inj: load_q[:] = self._dict_inj["load_q"] - load_set_bus = 1 * self._set_topo_vect[self.load_pos_topo_vect] - load_change_bus = copy.deepcopy(self._change_bus_vect[self.load_pos_topo_vect]) + load_set_bus = 1 * self._set_topo_vect[cls.load_pos_topo_vect] + load_change_bus = copy.deepcopy(self._change_bus_vect[cls.load_pos_topo_vect]) return load_p, load_q, load_set_bus, load_change_bus def get_gen_modif(self) -> Tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray]: @@ -3724,14 +3860,15 @@ def get_gen_modif(self) -> Tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray] New bus of the generators, affected with "change_bus" command """ - gen_p = np.full(self.n_load, fill_value=np.NaN, dtype=dt_float) + cls = type(self) + gen_p = np.full(cls.n_gen, fill_value=np.NaN, dtype=dt_float) if "prod_p" in self._dict_inj: gen_p[:] = self._dict_inj["prod_p"] gen_v = 1.0 * gen_p if "prod_v" in self._dict_inj: gen_v[:] = self._dict_inj["prod_v"] - gen_set_bus = 1 * self._set_topo_vect[self.gen_pos_topo_vect] - gen_change_bus = copy.deepcopy(self._change_bus_vect[self.gen_pos_topo_vect]) + gen_set_bus = 1 * self._set_topo_vect[cls.gen_pos_topo_vect] + gen_change_bus = copy.deepcopy(self._change_bus_vect[cls.gen_pos_topo_vect]) return gen_p, gen_v, gen_set_bus, gen_change_bus # TODO do the get_line_modif, get_line_or_modif and get_line_ex_modif @@ -3880,7 +4017,7 @@ def _aux_affect_object_int( ) el_id, new_bus = el if isinstance(el_id, str) and name_els is not None: - tmp = np.where(name_els == el_id)[0] + tmp = np.nonzero(name_els == el_id)[0] if len(tmp) == 0: raise IllegalAction(f"No known {name_el} with name {el_id}") el_id = tmp[0] @@ -3898,7 +4035,7 @@ def _aux_affect_object_int( # 2 cases: either key = load_id and value = new_bus or key = load_name and value = new bus for key, new_bus in values.items(): if isinstance(key, str) and name_els is not None: - tmp = np.where(name_els == key)[0] + tmp = np.nonzero(name_els == key)[0] if len(tmp) == 0: raise IllegalAction(f"No known {name_el} with name {key}") key = tmp[0] @@ -3921,9 +4058,35 @@ def _aux_affect_object_int( @property def load_set_bus(self) -> np.ndarray: """ - Allows to retrieve (and affect) the busbars at which each storage unit is **set**. + Allows to retrieve (and affect) the busbars at which the action **set** the loads. + + .. versionchanged:: 1.10.0 + From grid2op version 1.10.0 it is possible (under some cirumstances, depending on how + the environment is created) to set the busbar to a number >= 3, depending on the value + of `type(act).n_busbar_per_sub`. + + Returns + ------- + res: + A vector of integer, of size `act.n_gen` indicating what type of action is performed for + each load units with the convention : + + * 0 the action do not action on this load + * -1 the action disconnect the load + * 1 the action set the load to busbar 1 + * 2 the action set the load to busbar 2 + * 3 the action set the load to busbar 3 (grid2op >= 1.10.0) + * etc. (grid2op >= 1.10.0) + + Examples + -------- + + Please refer to the documentation of :attr:`BaseAction.gen_set_bus` for more information. + + .. note:: + Be careful not to mix "change" and "set". For "change" you only need to provide the ID of the elements + you want to change, for "set" you need to provide the ID **AND** where you want to set them. - It behaves similarly as :attr:`BaseAction.gen_set_bus`. See the help there for more information. """ res = self.set_bus[self.load_pos_topo_vect] res.flags.writeable = False @@ -3931,7 +4094,8 @@ def load_set_bus(self) -> np.ndarray: @load_set_bus.setter def load_set_bus(self, values): - if "set_bus" not in self.authorized_keys: + cls = type(self) + if "set_bus" not in cls.authorized_keys: raise IllegalAction( 'Impossible to modify the load bus (with "set") with this action type.' ) @@ -3940,20 +4104,22 @@ def load_set_bus(self, values): self._aux_affect_object_int( values, "load", - self.n_load, - self.name_load, - self.load_pos_topo_vect, + cls.n_load, + cls.name_load, + cls.load_pos_topo_vect, self._set_topo_vect, + max_val=cls.n_busbar_per_sub ) self._modif_set_bus = True except Exception as exc_: self._aux_affect_object_int( orig_, "load", - self.n_load, - self.name_load, - self.load_pos_topo_vect, + cls.n_load, + cls.name_load, + cls.load_pos_topo_vect, self._set_topo_vect, + max_val=cls.n_busbar_per_sub ) raise IllegalAction( f"Impossible to modify the load bus with your input. Please consult the documentation. " @@ -3965,21 +4131,28 @@ def gen_set_bus(self) -> np.ndarray: """ Allows to retrieve (and affect) the busbars at which the action **set** the generator units. + .. versionchanged:: 1.10.0 + From grid2op version 1.10.0 it is possible (under some cirumstances, depending on how + the environment is created) to set the busbar to a number >= 3, depending on the value + of `type(act).n_busbar_per_sub`. + Returns ------- res: A vector of integer, of size `act.n_gen` indicating what type of action is performed for each generator units with the convention : - * 0 the action do not action on this storage unit - * -1 the action disconnect the storage unit - * 1 the action set the storage unit to busbar 1 - * 2 the action set the storage unit to busbar 2 + * 0 the action do not action on this generator + * -1 the action disconnect the generator + * 1 the action set the generator to busbar 1 + * 2 the action set the generator to busbar 2 + * 3 the action set the generator to busbar 3 (grid2op >= 1.10.0) + * etc. (grid2op >= 1.10.0) Examples -------- - To retrieve the impact of the action on the storage unit, you can do: + To retrieve the impact of the action on the generator, you can do: .. code-block:: python @@ -4050,7 +4223,8 @@ def gen_set_bus(self) -> np.ndarray: act.gen_set_bus[1] = 2 # end do not run - .. note:: Be careful not to mix "change" and "set". For "change" you only need to provide the ID of the elements + .. note:: + Be careful not to mix "change" and "set". For "change" you only need to provide the ID of the elements you want to change, for "set" you need to provide the ID **AND** where you want to set them. """ @@ -4060,7 +4234,8 @@ def gen_set_bus(self) -> np.ndarray: @gen_set_bus.setter def gen_set_bus(self, values): - if "set_bus" not in self.authorized_keys: + cls = type(self) + if "set_bus" not in cls.authorized_keys: raise IllegalAction( 'Impossible to modify the gen bus (with "set") with this action type.' ) @@ -4069,20 +4244,22 @@ def gen_set_bus(self, values): self._aux_affect_object_int( values, "gen", - self.n_gen, - self.name_gen, - self.gen_pos_topo_vect, + cls.n_gen, + cls.name_gen, + cls.gen_pos_topo_vect, self._set_topo_vect, + max_val=cls.n_busbar_per_sub ) self._modif_set_bus = True except Exception as exc_: self._aux_affect_object_int( orig_, "gen", - self.n_gen, - self.name_gen, - self.gen_pos_topo_vect, + cls.n_gen, + cls.name_gen, + cls.gen_pos_topo_vect, self._set_topo_vect, + max_val=cls.n_busbar_per_sub ) raise IllegalAction( f"Impossible to modify the gen bus with your input. Please consult the documentation. " @@ -4092,9 +4269,35 @@ def gen_set_bus(self, values): @property def storage_set_bus(self) -> np.ndarray: """ - Allows to retrieve (and affect) the busbars at which each storage unit is **set**. + Allows to retrieve (and affect) the busbars at which the action **set** the storage units. + + .. versionchanged:: 1.10.0 + From grid2op version 1.10.0 it is possible (under some cirumstances, depending on how + the environment is created) to set the busbar to a number >= 3, depending on the value + of `type(act).n_busbar_per_sub`. + + Returns + ------- + res: + A vector of integer, of size `act.n_gen` indicating what type of action is performed for + each storage unit with the convention : + + * 0 the action do not action on this storage unit + * -1 the action disconnect the storage unit + * 1 the action set the storage unit to busbar 1 + * 2 the action set the storage unit to busbar 2 + * 3 the action set the storage unit to busbar 3 (grid2op >= 1.10.0) + * etc. (grid2op >= 1.10.0) + + Examples + -------- + + Please refer to the documentation of :attr:`BaseAction.gen_set_bus` for more information. + + .. note:: + Be careful not to mix "change" and "set". For "change" you only need to provide the ID of the elements + you want to change, for "set" you need to provide the ID **AND** where you want to set them. - It behaves similarly as :attr:`BaseAction.gen_set_bus`. See the help there for more information. """ if "set_storage" not in self.authorized_keys: raise IllegalAction(type(self).ERR_NO_STOR_SET_BUS) @@ -4104,29 +4307,32 @@ def storage_set_bus(self) -> np.ndarray: @storage_set_bus.setter def storage_set_bus(self, values): - if "set_bus" not in self.authorized_keys: - raise IllegalAction(type(self).ERR_NO_STOR_SET_BUS) - if "set_storage" not in self.authorized_keys: - raise IllegalAction(type(self).ERR_NO_STOR_SET_BUS) + cls = type(self) + if "set_bus" not in cls.authorized_keys: + raise IllegalAction(cls.ERR_NO_STOR_SET_BUS) + if "set_storage" not in cls.authorized_keys: + raise IllegalAction(cls.ERR_NO_STOR_SET_BUS) orig_ = self.storage_set_bus try: self._aux_affect_object_int( values, "storage", - self.n_storage, - self.name_storage, - self.storage_pos_topo_vect, + cls.n_storage, + cls.name_storage, + cls.storage_pos_topo_vect, self._set_topo_vect, + max_val=cls.n_busbar_per_sub ) self._modif_set_bus = True except Exception as exc_: self._aux_affect_object_int( orig_, "storage", - self.n_storage, - self.name_storage, - self.storage_pos_topo_vect, + cls.n_storage, + cls.name_storage, + cls.storage_pos_topo_vect, self._set_topo_vect, + max_val=cls.n_busbar_per_sub ) raise IllegalAction( f"Impossible to modify the storage bus with your input. " @@ -4137,9 +4343,35 @@ def storage_set_bus(self, values): @property def line_or_set_bus(self) -> np.ndarray: """ - Allows to retrieve (and affect) the busbars at which the origin side of each powerline is **set**. + Allows to retrieve (and affect) the busbars at which the action **set** the lines (origin side). + + .. versionchanged:: 1.10.0 + From grid2op version 1.10.0 it is possible (under some cirumstances, depending on how + the environment is created) to set the busbar to a number >= 3, depending on the value + of `type(act).n_busbar_per_sub`. + + Returns + ------- + res: + A vector of integer, of size `act.n_gen` indicating what type of action is performed for + each lines (origin side) with the convention : + + * 0 the action do not action on this line (origin side) + * -1 the action disconnect the line (origin side) + * 1 the action set the line (origin side) to busbar 1 + * 2 the action set the line (origin side) to busbar 2 + * 3 the action set the line (origin side) to busbar 3 (grid2op >= 1.10.0) + * etc. + + Examples + -------- + + Please refer to the documentation of :attr:`BaseAction.gen_set_bus` for more information. + + .. note:: + Be careful not to mix "change" and "set". For "change" you only need to provide the ID of the elements + you want to change, for "set" you need to provide the ID **AND** where you want to set them. - It behaves similarly as :attr:`BaseAction.gen_set_bus`. See the help there for more information. """ res = self.set_bus[self.line_or_pos_topo_vect] res.flags.writeable = False @@ -4147,7 +4379,8 @@ def line_or_set_bus(self) -> np.ndarray: @line_or_set_bus.setter def line_or_set_bus(self, values): - if "set_bus" not in self.authorized_keys: + cls = type(self) + if "set_bus" not in cls.authorized_keys: raise IllegalAction( 'Impossible to modify the line (origin) bus (with "set") with this action type.' ) @@ -4160,16 +4393,18 @@ def line_or_set_bus(self, values): self.name_line, self.line_or_pos_topo_vect, self._set_topo_vect, + max_val=cls.n_busbar_per_sub ) self._modif_set_bus = True except Exception as exc_: self._aux_affect_object_int( orig_, self._line_or_str, - self.n_line, - self.name_line, - self.line_or_pos_topo_vect, + cls.n_line, + cls.name_line, + cls.line_or_pos_topo_vect, self._set_topo_vect, + max_val=cls.n_busbar_per_sub ) raise IllegalAction( f"Impossible to modify the line origin bus with your input. " @@ -4190,7 +4425,8 @@ def line_ex_set_bus(self) -> np.ndarray: @line_ex_set_bus.setter def line_ex_set_bus(self, values): - if "set_bus" not in self.authorized_keys: + cls = type(self) + if "set_bus" not in cls.authorized_keys: raise IllegalAction( 'Impossible to modify the line (ex) bus (with "set") with this action type.' ) @@ -4199,20 +4435,22 @@ def line_ex_set_bus(self, values): self._aux_affect_object_int( values, self._line_ex_str, - self.n_line, - self.name_line, - self.line_ex_pos_topo_vect, + cls.n_line, + cls.name_line, + cls.line_ex_pos_topo_vect, self._set_topo_vect, + max_val=cls.n_busbar_per_sub ) self._modif_set_bus = True except Exception as exc_: self._aux_affect_object_int( orig_, self._line_ex_str, - self.n_line, - self.name_line, - self.line_ex_pos_topo_vect, + cls.n_line, + cls.name_line, + cls.line_ex_pos_topo_vect, self._set_topo_vect, + max_val=cls.n_busbar_per_sub ) raise IllegalAction( f"Impossible to modify the line extrmity bus with your input. " @@ -4267,7 +4505,8 @@ def set_bus(self) -> np.ndarray: @set_bus.setter def set_bus(self, values): - if "set_bus" not in self.authorized_keys: + cls = type(self) + if "set_bus" not in cls.authorized_keys: raise IllegalAction( 'Impossible to modify the bus (with "set") with this action type.' ) @@ -4276,20 +4515,22 @@ def set_bus(self, values): self._aux_affect_object_int( values, "", - self.dim_topo, + cls.dim_topo, None, - np.arange(self.dim_topo), + np.arange(cls.dim_topo), self._set_topo_vect, + max_val=cls.n_busbar_per_sub ) self._modif_set_bus = True except Exception as exc_: self._aux_affect_object_int( orig_, "", - self.dim_topo, + cls.dim_topo, None, - np.arange(self.dim_topo), + np.arange(cls.dim_topo), self._set_topo_vect, + max_val=cls.n_busbar_per_sub ) raise IllegalAction( f"Impossible to modify the bus with your input. " @@ -4483,7 +4724,7 @@ def _aux_affect_object_bool( # (note: i cannot convert to numpy array other I could mix types...) for el_id_or_name in values: if isinstance(el_id_or_name, str): - tmp = np.where(name_els == el_id_or_name)[0] + tmp = np.nonzero(name_els == el_id_or_name)[0] if len(tmp) == 0: raise IllegalAction( f'No known {name_el} with name "{el_id_or_name}"' @@ -5174,7 +5415,7 @@ def _aux_affect_object_float( ) el_id, new_val = el if isinstance(el_id, str): - tmp = np.where(name_els == el_id)[0] + tmp = np.nonzero(name_els == el_id)[0] if len(tmp) == 0: raise IllegalAction(f"No known {name_el} with name {el_id}") el_id = tmp[0] @@ -5190,7 +5431,7 @@ def _aux_affect_object_float( # 2 cases: either key = load_id and value = new_bus or key = load_name and value = new bus for key, new_val in values.items(): if isinstance(key, str): - tmp = np.where(name_els == key)[0] + tmp = np.nonzero(name_els == key)[0] if len(tmp) == 0: raise IllegalAction(f"No known {name_el} with name {key}") key = tmp[0] @@ -5471,7 +5712,7 @@ def _aux_aux_convert_and_check_np_array(self, array_): f"Impossible to set element to bus {np.min(array_)}. Buses must be " f"-1, 0, 1 or 2." ) - if (array_ > 2).any(): + if (array_ > type(self).n_busbar_per_sub).any(): raise IllegalAction( f"Impossible to set element to bus {np.max(array_)}. Buses must be " f"-1, 0, 1 or 2." @@ -5479,21 +5720,22 @@ def _aux_aux_convert_and_check_np_array(self, array_): return array_ def _aux_set_bus_sub(self, values): + cls = type(self) if isinstance(values, (bool, dt_bool)): raise IllegalAction( "Impossible to modify bus by substation with a single bool." ) - elif isinstance(values, (int, dt_int, np.int64)): + elif isinstance(values, (int, dt_int, np.int64, np.int32)): raise IllegalAction( "Impossible to modify bus by substation with a single integer." ) - elif isinstance(values, (float, dt_float, np.float64)): + elif isinstance(values, (float, dt_float, np.float64, np.float32)): raise IllegalAction( "Impossible to modify bus by substation with a single float." ) elif isinstance(values, np.ndarray): # full topo vect - if values.shape[0] != self.dim_topo: + if values.shape[0] != cls.dim_topo: raise IllegalAction( "Impossible to modify bus when providing a full topology vector " "that has not the right " @@ -5509,11 +5751,11 @@ def _aux_set_bus_sub(self, values): # should be a tuple (sub_id, new_topo) sub_id, topo_repr, nb_el = self._check_for_right_vectors_sub(values) topo_repr = self._aux_aux_convert_and_check_np_array(topo_repr) - start_ = self.sub_info[:sub_id].sum() + start_ = cls.sub_info[:sub_id].sum() end_ = start_ + nb_el self._set_topo_vect[start_:end_] = topo_repr elif isinstance(values, list): - if len(values) == self.dim_topo: + if len(values) == cls.dim_topo: # if list is the size of the full topo vect, it's a list representing it values = self._aux_aux_convert_and_check_np_array(values) self._aux_set_bus_sub(values) @@ -5686,7 +5928,7 @@ def _aux_change_bus_sub(self, values): def _aux_sub_when_dict_get_id(self, sub_id): if isinstance(sub_id, str): - tmp = np.where(self.name_sub == sub_id)[0] + tmp = np.nonzero(self.name_sub == sub_id)[0] if len(tmp) == 0: raise IllegalAction(f"No substation named {sub_id}") sub_id = tmp[0] @@ -5896,7 +6138,7 @@ def limit_curtail_storage(self, total_storage_consumed = res._storage_power.sum() # curtailment - gen_curtailed = (res._curtail != -1) & cls.gen_renewable + gen_curtailed = (np.abs(res._curtail + 1) >= 1e-7) & cls.gen_renewable gen_curtailed &= ( (obs.gen_p > res._curtail * cls.gen_pmax) | (obs.gen_p_before_curtail > obs.gen_p )) gen_p_after_max = (res._curtail * cls.gen_pmax)[gen_curtailed] @@ -5998,7 +6240,7 @@ def _aux_decompose_as_unary_actions_change_ls(self, cls, group_line_status, res) tmp._switch_line_status = copy.deepcopy(self._switch_line_status) res["change_line_status"] = [tmp] else: - lines_changed = np.where(self._switch_line_status)[0] + lines_changed = np.nonzero(self._switch_line_status)[0] res["change_line_status"] = [] for l_id in lines_changed: tmp = cls() @@ -6030,7 +6272,7 @@ def _aux_decompose_as_unary_actions_set_ls(self, cls, group_line_status, res): tmp._set_line_status = 1 * self._set_line_status res["set_line_status"] = [tmp] else: - lines_changed = np.where(self._set_line_status != 0)[0] + lines_changed = np.nonzero(self._set_line_status != 0)[0] res["set_line_status"] = [] for l_id in lines_changed: tmp = cls() @@ -6045,7 +6287,7 @@ def _aux_decompose_as_unary_actions_redisp(self, cls, group_redispatch, res): tmp._redispatch = 1. * self._redispatch res["redispatch"] = [tmp] else: - gen_changed = np.where(self._redispatch != 0.)[0] + gen_changed = np.nonzero(np.abs(self._redispatch) >= 1e-7)[0] res["redispatch"] = [] for g_id in gen_changed: tmp = cls() @@ -6060,7 +6302,7 @@ def _aux_decompose_as_unary_actions_storage(self, cls, group_storage, res): tmp._storage_power = 1. * self._storage_power res["set_storage"] = [tmp] else: - sto_changed = np.where(self._storage_power != 0.)[0] + sto_changed = np.nonzero(np.abs(self._storage_power) >= 1e-7)[0] res["set_storage"] = [] for s_id in sto_changed: tmp = cls() @@ -6075,7 +6317,7 @@ def _aux_decompose_as_unary_actions_curtail(self, cls, group_curtailment, res): tmp._curtail = 1. * self._curtail res["curtail"] = [tmp] else: - gen_changed = np.where(self._curtail != -1.)[0] + gen_changed = np.nonzero(np.abs(self._curtail + 1.) >= 1e-7)[0] #self._curtail != -1 res["curtail"] = [] for g_id in gen_changed: tmp = cls() @@ -6088,7 +6330,14 @@ def decompose_as_unary_actions(self, group_line_status=False, group_redispatch=True, group_storage=True, - group_curtail=True) -> dict: + group_curtail=True) -> Dict[Literal["change_bus", + "set_bus", + "change_line_status", + "set_line_status", + "redispatch", + "set_storage", + "curtail"], + List["BaseAction"]]: """This function allows to split a possibly "complex" action into its "unary" counterpart. diff --git a/grid2op/Action/serializableActionSpace.py b/grid2op/Action/serializableActionSpace.py index d7cee94cf..723da7527 100644 --- a/grid2op/Action/serializableActionSpace.py +++ b/grid2op/Action/serializableActionSpace.py @@ -9,8 +9,13 @@ import warnings import numpy as np import itertools -from typing import Dict, List +from typing import Dict, List, Literal +try: + from typing import Self +except ImportError: + from typing_extensions import Self +import grid2op from grid2op.dtypes import dt_int, dt_float, dt_bool from grid2op.Exceptions import AmbiguousAction, Grid2OpException from grid2op.Space import SerializableSpace @@ -112,11 +117,11 @@ def _get_possible_action_types(self): rnd_types.append(cls.CHANGE_BUS_ID) if "redispatch" in self.actionClass.authorized_keys: rnd_types.append(cls.REDISPATCHING_ID) - if self.n_storage > 0 and "storage_power" in self.actionClass.authorized_keys: + if cls.n_storage > 0 and "storage_power" in self.actionClass.authorized_keys: rnd_types.append(cls.STORAGE_POWER_ID) - if self.dim_alarms > 0 and "raise_alarm" in self.actionClass.authorized_keys: + if cls.dim_alarms > 0 and "raise_alarm" in self.actionClass.authorized_keys: rnd_types.append(cls.RAISE_ALARM_ID) - if self.dim_alerts > 0 and "raise_alert" in self.actionClass.authorized_keys: + if cls.dim_alerts > 0 and "raise_alert" in self.actionClass.authorized_keys: rnd_types.append(cls.RAISE_ALERT_ID) return rnd_types @@ -170,13 +175,9 @@ def supports_type(self, action_type): f"The action type provided should be in {name_action_types}. " f"You provided {action_type} which is not supported." ) - - if action_type == "storage_power": - return (self.n_storage > 0) and ( - "set_storage" in self.actionClass.authorized_keys - ) - elif action_type == "set_storage": - return (self.n_storage > 0) and ( + cls = type(self) + if action_type == "storage_power" or action_type == "set_storage": + return (cls.n_storage > 0) and ( "set_storage" in self.actionClass.authorized_keys ) elif action_type == "curtail_mw": @@ -246,8 +247,10 @@ def _sample_storage_power(self, rnd_update=None): return rnd_update def _sample_raise_alarm(self, rnd_update=None): - """.. warning:: + """ + .. warning:: /!\\\\ Only valid with "l2rpn_icaps_2021" environment /!\\\\ + """ if rnd_update is None: rnd_update = {} @@ -256,13 +259,18 @@ def _sample_raise_alarm(self, rnd_update=None): return rnd_update def _sample_raise_alert(self, rnd_update=None): + """ + .. warning:: + Not available in all environments. + + """ if rnd_update is None: rnd_update = {} rnd_alerted_lines = self.space_prng.choice([True, False], self.dim_alerts).astype(dt_bool) rnd_update["raise_alert"] = rnd_alerted_lines return rnd_update - def sample(self): + def sample(self) -> BaseAction: """ A utility used to sample a new random :class:`BaseAction`. @@ -303,7 +311,7 @@ def sample(self): env = grid2op.make("l2rpn_case14_sandbox") # and now you can sample from the action space - random_action = env.action_space() + random_action = env.action_space() # this action is not random at all, it starts by "do nothing" for i in range(5): # my resulting action will be a complex action # that will be the results of applying 5 random actions @@ -322,22 +330,22 @@ def sample(self): # this sampling rnd_type = self.space_prng.choice(rnd_types) - - if rnd_type == self.SET_STATUS_ID: + cls = type(self) + if rnd_type == cls.SET_STATUS_ID: rnd_update = self._sample_set_line_status() - elif rnd_type == self.CHANGE_STATUS_ID: + elif rnd_type == cls.CHANGE_STATUS_ID: rnd_update = self._sample_change_line_status() - elif rnd_type == self.SET_BUS_ID: + elif rnd_type == cls.SET_BUS_ID: rnd_update = self._sample_set_bus() - elif rnd_type == self.CHANGE_BUS_ID: + elif rnd_type == cls.CHANGE_BUS_ID: rnd_update = self._sample_change_bus() - elif rnd_type == self.REDISPATCHING_ID: + elif rnd_type == cls.REDISPATCHING_ID: rnd_update = self._sample_redispatch() - elif rnd_type == self.STORAGE_POWER_ID: + elif rnd_type == cls.STORAGE_POWER_ID: rnd_update = self._sample_storage_power() - elif rnd_type == self.RAISE_ALARM_ID: + elif rnd_type == cls.RAISE_ALARM_ID: rnd_update = self._sample_raise_alarm() - elif rnd_type == self.RAISE_ALERT_ID: + elif rnd_type == cls.RAISE_ALERT_ID: rnd_update = self._sample_raise_alert() else: raise Grid2OpException( @@ -347,7 +355,10 @@ def sample(self): rnd_act.update(rnd_update) return rnd_act - def disconnect_powerline(self, line_id=None, line_name=None, previous_action=None): + def disconnect_powerline(self, + line_id: int=None, + line_name: str=None, + previous_action: BaseAction=None) -> BaseAction: """ Utilities to disconnect a powerline more easily. @@ -396,6 +407,7 @@ def disconnect_powerline(self, line_id=None, line_name=None, previous_action=Non # after the last call! """ + cls = type(self) if line_id is None and line_name is None: raise AmbiguousAction( 'You need to provide either the "line_id" or the "line_name" of the powerline ' @@ -408,11 +420,11 @@ def disconnect_powerline(self, line_id=None, line_name=None, previous_action=Non ) if line_id is None: - line_id = np.where(self.name_line == line_name)[0] + line_id = np.nonzero(cls.name_line == line_name)[0] if not len(line_id): raise AmbiguousAction( 'Line with name "{}" is not on the grid. The powerlines names are:\n{}' - "".format(line_name, self.name_line) + "".format(line_name, cls.name_line) ) if previous_action is None: res = self.actionClass() @@ -422,17 +434,22 @@ def disconnect_powerline(self, line_id=None, line_name=None, previous_action=Non type(self).ERR_MSG_WRONG_TYPE.format(type(previous_action), self.actionClass) ) res = previous_action - if line_id > self.n_line: + if line_id > cls.n_line: raise AmbiguousAction( "You asked to disconnect powerline of id {} but this id does not exist. The " - "grid counts only {} powerline".format(line_id, self.n_line) + "grid counts only {} powerline".format(line_id, cls.n_line) ) res.update({"set_line_status": [(line_id, -1)]}) return res def reconnect_powerline( - self, bus_or, bus_ex, line_id=None, line_name=None, previous_action=None - ): + self, + bus_or: int, + bus_ex: int, + line_id: int=None, + line_name: str=None, + previous_action: BaseAction=None + ) -> BaseAction: """ Utilities to reconnect a powerline more easily. @@ -457,10 +474,10 @@ def reconnect_powerline( The powerline to be disconnected. bus_or: ``int`` - On which bus to reconnect the powerline at its origin end + On which bus to reconnect the powerline at its origin side bus_ex: ``int`` - On which bus to reconnect the powerline at its extremity end + On which bus to reconnect the powerline at its extremity side previous_action Returns @@ -503,19 +520,19 @@ def reconnect_powerline( 'You need to provide only of the "line_id" or the "line_name" of the powerline ' "you want to reconnect" ) - + cls = type(self) if line_id is None: - line_id = np.where(self.name_line == line_name)[0] + line_id = np.nonzero(cls.name_line == line_name)[0] if previous_action is None: res = self.actionClass() else: if not isinstance(previous_action, self.actionClass): raise AmbiguousAction( - type(self).ERR_MSG_WRONG_TYPE.format(type(previous_action), self.actionClass) + cls.ERR_MSG_WRONG_TYPE.format(type(previous_action), self.actionClass) ) res = previous_action - if line_id > self.n_line: + if line_id > cls.n_line: raise AmbiguousAction( "You asked to disconnect powerline of id {} but this id does not exist. The " "grid counts only {} powerline".format(line_id, self.n_line) @@ -533,12 +550,12 @@ def reconnect_powerline( def change_bus( self, - name_element, - extremity=None, - substation=None, - type_element=None, - previous_action=None, - ): + name_element : str, + extremity : Literal["or", "ex"] =None, + substation: int=None, + type_element :str=None, + previous_action: BaseAction=None, + ) -> BaseAction: """ Utilities to change the bus of a single element if you give its name. **NB** Changing a bus has the effect to assign the object to bus 1 if it was before that connected to bus 2, and to assign it to bus 2 if it was @@ -557,7 +574,7 @@ def change_bus( Its substation ID, if you know it will increase the performance. Otherwise, the method will search for it. type_element: ``str``, optional Type of the element to look for. It is here to speed up the computation. One of "line", "gen" or "load" - previous_action: :class:`Action`, optional + previous_action: :class:`BaseAction`, optional The (optional) action to update. It should be of the same type as :attr:`ActionSpace.actionClass` Notes @@ -622,15 +639,16 @@ def change_bus( res.update({"change_bus": {"substations_id": [(my_sub_id, arr_)]}}) return res - def _extract_database_powerline(self, extremity): + @classmethod + def _extract_database_powerline(cls, extremity: Literal["or", "ex"]): if extremity[:2] == "or": - to_subid = self.line_or_to_subid - to_sub_pos = self.line_or_to_sub_pos - to_name = self.name_line + to_subid = cls.line_or_to_subid + to_sub_pos = cls.line_or_to_sub_pos + to_name = cls.name_line elif extremity[:2] == "ex": - to_subid = self.line_ex_to_subid - to_sub_pos = self.line_ex_to_sub_pos - to_name = self.name_line + to_subid = cls.line_ex_to_subid + to_sub_pos = cls.line_ex_to_sub_pos + to_name = cls.name_line elif extremity is None: raise Grid2OpException( "It is mandatory to know on which ends you want to change the bus of the powerline" @@ -653,18 +671,18 @@ def _extract_dict_action( to_subid = None to_sub_pos = None to_name = None - + cls = type(self) if type_element is None: # i have to look through all the objects to find it - if name_element in self.name_load: - to_subid = self.load_to_subid - to_sub_pos = self.load_to_sub_pos - to_name = self.name_load - elif name_element in self.name_gen: - to_subid = self.gen_to_subid - to_sub_pos = self.gen_to_sub_pos - to_name = self.name_gen - elif name_element in self.name_line: + if name_element in cls.name_load: + to_subid = cls.load_to_subid + to_sub_pos = cls.load_to_sub_pos + to_name = cls.name_load + elif name_element in cls.name_gen: + to_subid = cls.gen_to_subid + to_sub_pos = cls.gen_to_sub_pos + to_name = cls.name_gen + elif name_element in cls.name_line: to_subid, to_sub_pos, to_name = self._extract_database_powerline( extremity ) @@ -675,13 +693,13 @@ def _extract_dict_action( elif type_element == "line": to_subid, to_sub_pos, to_name = self._extract_database_powerline(extremity) elif type_element[:3] == "gen" or type_element[:4] == "prod": - to_subid = self.gen_to_subid - to_sub_pos = self.gen_to_sub_pos - to_name = self.name_gen + to_subid = cls.gen_to_subid + to_sub_pos = cls.gen_to_sub_pos + to_name = cls.name_gen elif type_element == "load": - to_subid = self.load_to_subid - to_sub_pos = self.load_to_sub_pos - to_name = self.name_load + to_subid = cls.load_to_subid + to_sub_pos = cls.load_to_sub_pos + to_name = cls.name_load else: raise AmbiguousAction( 'unknown type_element specifier "{}". type_element should be "line" or "load" ' @@ -704,13 +722,13 @@ def _extract_dict_action( def set_bus( self, - name_element, - new_bus, - extremity=None, - substation=None, - type_element=None, - previous_action=None, - ): + name_element :str, + new_bus :int, + extremity: Literal["or", "ex"]=None, + substation: int=None, + type_element: int=None, + previous_action: BaseAction=None, + ) -> BaseAction: """ Utilities to set the bus of a single element if you give its name. **NB** Setting a bus has the effect to assign the object to this bus. If it was before that connected to bus 1, and you assign it to bus 1 (*new_bus* @@ -737,7 +755,7 @@ def set_bus( type_element: ``str``, optional Type of the element to look for. It is here to speed up the computation. One of "line", "gen" or "load" - previous_action: :class:`Action`, optional + previous_action: :class:`BaseAction`, optional The (optional) action to update. It should be of the same type as :attr:`ActionSpace.actionClass` Returns @@ -791,7 +809,7 @@ def set_bus( res.update({"set_bus": {"substations_id": [(my_sub_id, dict_["set_bus"])]}}) return res - def get_set_line_status_vect(self): + def get_set_line_status_vect(self) -> np.ndarray: """ Computes and returns a vector that can be used in the "set_status" keyword if building an :class:`BaseAction` @@ -803,7 +821,7 @@ def get_set_line_status_vect(self): """ return self._template_act.get_set_line_status_vect() - def get_change_line_status_vect(self): + def get_change_line_status_vect(self) -> np.ndarray: """ Computes and return a vector that can be used in the "change_line_status" keyword if building an :class:`BaseAction` @@ -816,11 +834,12 @@ def get_change_line_status_vect(self): return self._template_act.get_change_line_status_vect() @staticmethod - def get_all_unitary_line_set(action_space): + def get_all_unitary_line_set(action_space: Self) -> List[BaseAction]: """ Return all unitary actions that "set" powerline status. - For each powerline, there are 5 such actions: + For each powerline, if there are 2 busbars per substation, + there are 5 such actions: - disconnect it - connected it origin at bus 1 and extremity at bus 1 @@ -828,9 +847,18 @@ def get_all_unitary_line_set(action_space): - connected it origin at bus 2 and extremity at bus 1 - connected it origin at bus 2 and extremity at bus 2 + This number increases quite rapidly if there are more busbars + allowed per substation of course. For example if you allow + for 3 busbars per substations, it goes from (1 + 2*2) [=5] + to (1 + 3 * 3) [=10] and if you allow for 4 busbars per substations + you end up with (1 + 4 * 4) [=17] possible actions per powerline. + + .. seealso:: + :func:`SerializableActionSpace.get_all_unitary_line_set_simple` + Parameters ---------- - action_space: :class:`grid2op.BaseAction.ActionSpace` + action_space: :class:`ActionSpace` The action space used. Returns @@ -840,24 +868,23 @@ def get_all_unitary_line_set(action_space): """ res = [] - + cls = type(action_space) # powerline switch: disconnection - for i in range(action_space.n_line): - res.append(action_space.disconnect_powerline(line_id=i)) - - # powerline switch: reconnection - for bus_or in [1, 2]: - for bus_ex in [1, 2]: - for i in range(action_space.n_line): - act = action_space.reconnect_powerline( - line_id=i, bus_ex=bus_ex, bus_or=bus_or - ) - res.append(act) + for i in range(cls.n_line): + res.append(action_space.disconnect_powerline(line_id=i)) + + all_busbars = list(range(1, cls.n_busbar_per_sub + 1)) + for bus1, bus2 in itertools.product(all_busbars, all_busbars): + for i in range(cls.n_line): + act = action_space.reconnect_powerline( + line_id=i, bus_ex=bus1, bus_or=bus2 + ) + res.append(act) return res @staticmethod - def get_all_unitary_line_set_simple(action_space): + def get_all_unitary_line_set_simple(action_space: Self) -> List[BaseAction]: """ Return all unitary actions that "set" powerline status but in a more simple way than :func:`SerializableActionSpace.get_all_unitary_line_set` @@ -869,12 +896,19 @@ def get_all_unitary_line_set_simple(action_space): side used to be connected) It has the main advantages to "only" add 2 actions per powerline - instead of 5. + instead of 5 (if the number of busbars per substation is 2). + + Using this method, powerlines will always be reconnected to their + previous busbars (the last known one) and you will always get + exactly 2 actions per powerlines. + + .. seealso:: + :func:`SerializableActionSpace.get_all_unitary_line_set` Parameters ---------- - action_space: :class:`grid2op.BaseAction.ActionSpace` + action_space: :class:`ActionSpace` The action space used. Returns @@ -884,32 +918,33 @@ def get_all_unitary_line_set_simple(action_space): """ res = [] - + cls = type(action_space) # powerline set: disconnection - for i in range(action_space.n_line): + for i in range(cls.n_line): res.append(action_space({"set_line_status": [(i,-1)]})) # powerline set: reconnection - for i in range(action_space.n_line): + for i in range(cls.n_line): res.append(action_space({"set_line_status": [(i, +1)]})) return res @staticmethod - def get_all_unitary_alarm(action_space): + def get_all_unitary_alarm(action_space: Self) -> List[BaseAction]: """ .. warning:: /!\\\\ Only valid with "l2rpn_icaps_2021" environment /!\\\\ """ + cls = type(action_space) res = [] - for i in range(action_space.dim_alarms): - status = np.full(action_space.dim_alarms, fill_value=False, dtype=dt_bool) + for i in range(cls.dim_alarms): + status = np.full(cls.dim_alarms, fill_value=False, dtype=dt_bool) status[i] = True res.append(action_space({"raise_alarm": status})) return res @staticmethod - def get_all_unitary_alert(action_space): + def get_all_unitary_alert(action_space: Self) -> List[BaseAction]: """ Return all unitary actions that raise an alert on powerlines. @@ -918,15 +953,16 @@ def get_all_unitary_alert(action_space): If you got 22 attackable lines, then you got 2**22 actions... probably a TERRIBLE IDEA ! """ + cls = type(action_space) res = [] possible_values = [False, True] - if action_space.dim_alerts: - for status in itertools.product(possible_values, repeat=type(action_space).dim_alerts): + if cls.dim_alerts: + for status in itertools.product(possible_values, repeat=cls.dim_alerts): res.append(action_space({"raise_alert": np.array(status, dtype=dt_bool)})) return res @staticmethod - def get_all_unitary_line_change(action_space): + def get_all_unitary_line_change(action_space: Self) -> List[BaseAction]: """ Return all unitary actions that "change" powerline status. @@ -934,7 +970,7 @@ def get_all_unitary_line_change(action_space): Parameters ---------- - action_space: :class:`grid2op.BaseAction.ActionSpace` + action_space: :class:`ActionSpace` The action space used. Returns @@ -943,15 +979,16 @@ def get_all_unitary_line_change(action_space): The list of all "change" action acting on powerline status """ + cls = type(action_space) res = [] - for i in range(action_space.n_line): + for i in range(cls.n_line): status = action_space.get_change_line_status_vect() status[i] = True res.append(action_space({"change_line_status": status})) return res @staticmethod - def get_all_unitary_topologies_change(action_space, sub_id=None): + def get_all_unitary_topologies_change(action_space: Self, sub_id : int=None) -> List[BaseAction]: """ This methods allows to compute and return all the unitary topological changes that can be performed on a powergrid. @@ -960,7 +997,7 @@ def get_all_unitary_topologies_change(action_space, sub_id=None): Parameters ---------- - action_space: :class:`grid2op.BaseAction.ActionSpace` + action_space: :class:`ActionSpace` The action space used. sub_id: ``int``, optional @@ -991,9 +1028,14 @@ def get_all_unitary_topologies_change(action_space, sub_id=None): all_change_actions_sub4 = env.action_space.get_all_unitary_topologies_change(env.action_space, sub_id=4) """ + cls = type(action_space) + if cls.n_busbar_per_sub == 1 or cls.n_busbar_per_sub >= 3: + raise Grid2OpException("Impossible to use `change_bus` action type " + "if your grid does not have exactly 2 busbars " + "per substation") res = [] S = [0, 1] - for sub_id_, num_el in enumerate(action_space.sub_info): + for sub_id_, num_el in enumerate(cls.sub_info): if sub_id is not None: if sub_id_ != sub_id: continue @@ -1020,8 +1062,114 @@ def get_all_unitary_topologies_change(action_space, sub_id=None): # a substation, changing A,B or changing C,D always has the same effect. return res + @classmethod + def _is_ok_symmetry(cls, n_busbar_per_sub: int, tup: np.ndarray, bus_start: int=2, id_start: int=1) -> bool: + # id_start: at which index to start in the `tup` vector + # bus_start: which maximum bus id should be present there + # tup: the topology vector + if id_start >= len(tup): + # i reached the end of the tuple + return True + if bus_start >= n_busbar_per_sub: + # all previous buses are filled + return True + + this_bus = tup[id_start] + if this_bus < bus_start: + # this bus id is already assigned + # go to next id, + return cls._is_ok_symmetry(n_busbar_per_sub, tup, bus_start, id_start + 1) + else: + if this_bus == bus_start: + # This is a new bus and it has the correct id + # so I go to next + return cls._is_ok_symmetry(n_busbar_per_sub, tup, bus_start + 1, id_start + 1) + else: + # by symmetry the "current" bus should be relabeled `bus_start` + # which is alreay added somewhere else. The current topologie + # is not valid. + return False + + @classmethod + def _is_ok_line(cls, n_busbar_per_sub: int, tup: np.ndarray, lines_id: np.ndarray) -> bool: + """check there are at least a line connected to each buses""" + # now, this is the "smart" thing: + # as the bus should be labelled "in order" (no way we can add + # bus 3 if bus 2 is not already set in `tup` because of the + # `_is_ok_symmetry` function), I know for a fact that there is + # `tup.max()` active buses in this topology. + # So to make sure that every buses has at least a line connected to it + # then I just check the number of unique buses (tup.max()) + # and compare it to the number of buses where there are + # at least a line len(buses_with_lines) + + # NB the alternative implementation is slower + # >>> buses_with_lines = np.unique(tup[lines_id]) + # >>> return buses_with_lines.size == tup.max() + nb = 0 + only_line = tup[lines_id] + for el in range(1, n_busbar_per_sub +1): + nb += (only_line == el).any() + return nb == tup.max() + + @classmethod + def _is_ok_2(cls, n_busbar_per_sub : int, tup) -> bool: + """check there are at least 2 elements per busbars""" + # now, this is the "smart" thing: + # as the bus should be labelled "in order" (no way we can add + # bus 3 if bus 2 is not already set in `tup` because of the + # `_is_ok_symmetry` function), I know for a fact that there is + # `tup.max()` active buses in this topology. + # So to make sure that every buses has at least a line connected to it + # then I just check the number of unique buses (tup.max()) + # and compare it to the number of buses where there are + # at least a line len(buses_with_lines) + + + # NB the alternative implementation is slower + # >>> un_, count = np.unique(tup, return_counts=True) + # >>> return (count >= 2).all() + for el in range(1, tup.max() + 1): + if (tup == el).sum() < 2: + return False + return True + + @staticmethod + def _aux_get_all_unitary_topologies_set_comp_topo(busbar_set, num_el, action_space, + cls, powerlines_id, add_alone_line, + _count_only, sub_id_): + if not _count_only: + tmp = [] + else: + tmp = 0 + + for tup in itertools.product(busbar_set, repeat=num_el - 1): + tup = np.array((1, *tup)) # force first el on bus 1 to break symmetry + + if not action_space._is_ok_symmetry(cls.n_busbar_per_sub, tup): + # already added (by symmetry) + continue + if not action_space._is_ok_line(cls.n_busbar_per_sub, tup, powerlines_id): + # check there is at least one line per busbars + continue + if not add_alone_line and not action_space._is_ok_2(cls.n_busbar_per_sub, tup): + # check there are at least 2 elements per buses + continue + + if not _count_only: + action = action_space( + {"set_bus": {"substations_id": [(sub_id_, tup)]}} + ) + tmp.append(action) + else: + tmp += 1 + return tmp + @staticmethod - def get_all_unitary_topologies_set(action_space, sub_id=None): + def get_all_unitary_topologies_set(action_space: Self, + sub_id: int=None, + add_alone_line=True, + _count_only=False) -> List[BaseAction]: """ This methods allows to compute and return all the unitary topological changes that can be performed on a powergrid. @@ -1029,14 +1177,60 @@ def get_all_unitary_topologies_set(action_space, sub_id=None): The changes will be performed using the "set_bus" method. The "do nothing" action will be counted once per substation in the grid. + It returns all the "valid" topologies available at any substation (if `sub_id` is ``None`` -default) + or at the requested substation. + + To be valid a topology must satisfy: + + - there are at least one side of the powerline connected to each busbar (there cannot be a load alone + on a bus or a generator alone on a bus for example) + - if `add_alone_line=False` (not the default) then there must be at least two elements in a + substation + + .. note:: + We try to make the result of this function as small as possible. This means that if at any + substation the number of "valid" topology is only 1, it is ignored and will not be added + in the result. + + This imply that when `env.n_busbar_per_sub=1` then this function returns the empty list. + + .. note:: + If `add_alone_line` is True (again NOT the default) then if any substation counts less than + 3 elements or less then no action will be added for this substation. + + If there are 4 or 5 elements at a substation (and add_alone_line=False), then only topologies + using 2 busbar will be used. + + .. warning:: + This generates only topologies were all elements are connected. It does not generate + topologies with disconnected lines. + + .. warning:: + As far as we know, there are no bugs in this implementation. However we did not spend + lots of time finding a "closed form" formula to count exactly the number of possible topologies. + This means that we might have missed some topologies or counted the same "results" multiple + times if there has been an error in the symmetries. + + If you are interested in this topic, let us know with a discussion, for example here + https://github.com/rte-france/Grid2Op/discussions + Parameters ---------- - action_space: :class:`grid2op.BaseAction.ActionHelper` + action_space: :class:`ActionSpace` The action space used. sub_id: ``int``, optional The substation ID. If ``None`` it is done for all substations. + add_alone_line: ``bool``, optional + If ``True`` (default) then topologiees where 1 line side is "alone" on a bus + are valid and put in the output (more topologies are considered). If not + then only topologies with at least one line AND 2 elements per buses + are returned. + + _count_only: ``bool``, optional + Does not return the list but rather only the number of elements there would be + Notes ----- This might take a long time on large grid (possibly 10-15 mins for the IEEE 118 for example) @@ -1062,80 +1256,50 @@ def get_all_unitary_topologies_set(action_space, sub_id=None): all_change_actions_sub4 = env.action_space.get_all_unitary_topologies_set(env.action_space, sub_id=4) """ + cls = type(action_space) + if cls.n_busbar_per_sub == 1: + return [] + res = [] - S = [0, 1] - for sub_id_, num_el in enumerate(action_space.sub_info): - tmp = [] - if sub_id is not None: - if sub_id_ != sub_id: - continue - - new_topo = np.full(shape=num_el, fill_value=1, dtype=dt_int) - # perform the action "set everything on bus 1" - action = action_space( - {"set_bus": {"substations_id": [(sub_id_, new_topo)]}} - ) - tmp.append(action) - - powerlines_or_id = action_space.line_or_to_sub_pos[ - action_space.line_or_to_subid == sub_id_ - ] - powerlines_ex_id = action_space.line_ex_to_sub_pos[ - action_space.line_ex_to_subid == sub_id_ - ] - powerlines_id = np.concatenate((powerlines_or_id, powerlines_ex_id)) - + S = list(range(1, cls.n_busbar_per_sub + 1)) + if sub_id is not None: + num_el = cls.sub_info[sub_id] + powerlines_id = cls.get_powerline_id(sub_id) + # computes all the topologies at 2 buses for this substation - for tup in itertools.product(S, repeat=num_el - 1): - indx = np.full(shape=num_el, fill_value=False, dtype=dt_bool) - tup = np.array((0, *tup)).astype( - dt_bool - ) # add a zero to first element -> break symmetry - indx[tup] = True - if indx.sum() >= 2 and (~indx).sum() >= 2: - # i need 2 elements on each bus at least (almost all the times, except when a powerline - # is alone on its bus) - new_topo = np.full(shape=num_el, fill_value=1, dtype=dt_int) - new_topo[~indx] = 2 - - if ( - indx[powerlines_id].sum() == 0 - or (~indx[powerlines_id]).sum() == 0 - ): - # if there is a "node" without a powerline, the topology is not valid - continue + tmp = action_space._aux_get_all_unitary_topologies_set_comp_topo(S, num_el, action_space, + cls, powerlines_id, add_alone_line, + _count_only, sub_id) - action = action_space( - {"set_bus": {"substations_id": [(sub_id_, new_topo)]}} - ) - tmp.append(action) - else: - # i need to take into account the case where 1 powerline is alone on a bus too - if ( - (indx[powerlines_id]).sum() >= 1 - and (~indx[powerlines_id]).sum() >= 1 - ): - new_topo = np.full(shape=num_el, fill_value=1, dtype=dt_int) - new_topo[~indx] = 2 - action = action_space( - {"set_bus": {"substations_id": [(sub_id_, new_topo)]}} - ) - tmp.append(action) - - if len(tmp) >= 2: + if not _count_only and len(tmp) >= 2: # if i have only one single topology on this substation, it doesn't make any action - # i cannot change the topology is there is only one. + # i cannot change the topology if there is only one. res += tmp - + elif _count_only: + if tmp >= 2: + res = tmp + else: + # no real way to change if there is only one valid topology + res = 0 + if not _count_only: + return res + return [res] # need to be a list still + + for sub_id in range(cls.n_sub): + this = cls.get_all_unitary_topologies_set(action_space, + sub_id, + add_alone_line, + _count_only) + res += this return res @staticmethod def get_all_unitary_redispatch( action_space, num_down=5, num_up=5, max_ratio_value=1.0 - ): + ) -> List[BaseAction]: """ Redispatching action are continuous action. This method is an helper to convert the continuous - action into discrete action (by rounding). + action into "discrete actions" (by rounding). The number of actions is equal to num_down + num_up (by default 10) per dispatchable generator. @@ -1146,10 +1310,14 @@ def get_all_unitary_redispatch( a distinct action (then counting `num_down` different action, because 0.0 is removed) - it will do the same for [0, gen_maw_ramp_up] + .. note:: + With this "helper" only one generator is affected by one action. For example + there are no action acting on both generator 1 and generator 2 at the same + time. Parameters ---------- - action_space: :class:`grid2op.BaseAction.ActionHelper` + action_space: :class:`ActionSpace` The action space used. num_down: ``int`` @@ -1204,7 +1372,7 @@ def get_all_unitary_redispatch( return res @staticmethod - def get_all_unitary_curtail(action_space, num_bin=10, min_value=0.5): + def get_all_unitary_curtail(action_space : Self, num_bin: int=10, min_value: float=0.5) -> List[BaseAction]: """ Curtailment action are continuous action. This method is an helper to convert the continuous action into discrete action (by rounding). @@ -1218,17 +1386,21 @@ def get_all_unitary_curtail(action_space, num_bin=10, min_value=0.5): - it will divide the interval [0, 1] into `num_bin`, each will make a distinct action (then counting `num_bin` different action, because 0.0 is removed) + .. note:: + With this "helper" only one generator is affected by one action. For example + there are no action acting on both generator 1 and generator 2 at the same + time. Parameters ---------- - action_space: :class:`grid2op.BaseAction.ActionHelper` + action_space: :class:`ActionSpace` The action space used. num_bin: ``int`` Number of actions for each renewable generator min_value: ``float`` - Between 0. and 1.: minimum value allow for the curtailment. FOr example if you set this + Between 0. and 1.: minimum value allow for the curtailment. For example if you set this value to be 0.2 then no curtailment will be done to limit the generator below 20% of its maximum capacity Returns @@ -1255,7 +1427,7 @@ def get_all_unitary_curtail(action_space, num_bin=10, min_value=0.5): return res @staticmethod - def get_all_unitary_storage(action_space, num_down=5, num_up=5): + def get_all_unitary_storage(action_space: Self, num_down: int =5, num_up: int=5) -> List[BaseAction]: """ Storage action are continuous action. This method is an helper to convert the continuous action into discrete action (by rounding). @@ -1269,10 +1441,15 @@ def get_all_unitary_storage(action_space, num_down=5, num_up=5): a distinct action (then counting `num_down` different action, because 0.0 is removed) - it will do the same for [0, storage_max_p_absorb] + .. note:: + With this "helper" only one storage unit is affected by one action. For example + there are no action acting on both storage unit 1 and storage unit 2 at the same + time. + Parameters ---------- - action_space: :class:`grid2op.BaseAction.ActionHelper` + action_space: :class:`ActionSpace` The action space used. Returns @@ -1316,7 +1493,7 @@ def _custom_deepcopy_for_copy(self, new_obj): new_obj._template_act = self.actionClass() def _aux_get_back_to_ref_state_curtail(self, res, obs): - is_curtailed = obs.curtailment_limit != 1.0 + is_curtailed = np.abs(obs.curtailment_limit - 1.0) >= 1e-7 if is_curtailed.any(): res["curtailment"] = [] if not self.supports_type("curtail"): @@ -1334,7 +1511,7 @@ def _aux_get_back_to_ref_state_curtail(self, res, obs): def _aux_get_back_to_ref_state_line(self, res, obs): disc_lines = ~obs.line_status if disc_lines.any(): - li_disc = np.where(disc_lines)[0] + li_disc = np.nonzero(disc_lines)[0] res["powerline"] = [] for el in li_disc: act = self.actionClass() @@ -1376,9 +1553,9 @@ def _aux_get_back_to_ref_state_sub(self, res, obs): def _aux_get_back_to_ref_state_redisp(self, res, obs, precision=1e-5): # TODO this is ugly, probably slow and could definitely be optimized - notredisp_setpoint = obs.target_dispatch != 0.0 + notredisp_setpoint = np.abs(obs.target_dispatch) >= 1e-7 if notredisp_setpoint.any(): - need_redisp = np.where(notredisp_setpoint)[0] + need_redisp = np.nonzero(notredisp_setpoint)[0] res["redispatching"] = [] # combine generators and do not exceed ramps (up or down) rem = np.zeros(self.n_gen, dtype=dt_float) @@ -1417,14 +1594,14 @@ def _aux_get_back_to_ref_state_redisp(self, res, obs, precision=1e-5): continue if obs.target_dispatch[gen_id] > 0.0: if nb_act < nb_[gen_id] - 1 or ( - rem[gen_id] == 0.0 and nb_act == nb_[gen_id] - 1 + np.abs(rem[gen_id]) <= 1e-7 and nb_act == nb_[gen_id] - 1 ): reds[gen_id] = -obs.gen_max_ramp_down[gen_id] else: reds[gen_id] = -rem[gen_id] else: if nb_act < nb_[gen_id] - 1 or ( - rem[gen_id] == 0.0 and nb_act == nb_[gen_id] - 1 + np.abs(rem[gen_id]) <= 1e-7 and nb_act == nb_[gen_id] - 1 ): reds[gen_id] = obs.gen_max_ramp_up[gen_id] else: @@ -1443,7 +1620,7 @@ def _aux_get_back_to_ref_state_storage( notredisp_setpoint = obs.storage_charge / obs.storage_Emax != storage_setpoint delta_time_hour = dt_float(obs.delta_time / 60.0) if notredisp_setpoint.any(): - need_ajust = np.where(notredisp_setpoint)[0] + need_ajust = np.nonzero(notredisp_setpoint)[0] res["storage"] = [] # combine storage units and do not exceed maximum power rem = np.zeros(self.n_storage, dtype=dt_float) @@ -1488,14 +1665,14 @@ def _aux_get_back_to_ref_state_storage( continue if current_state[stor_id] > 0.0: if nb_act < nb_[stor_id] - 1 or ( - rem[stor_id] == 0.0 and nb_act == nb_[stor_id] - 1 + np.abs(rem[stor_id]) <= 1e-7 and nb_act == nb_[stor_id] - 1 ): reds[stor_id] = -obs.storage_max_p_prod[stor_id] else: reds[stor_id] = -rem[stor_id] else: if nb_act < nb_[stor_id] - 1 or ( - rem[stor_id] == 0.0 and nb_act == nb_[stor_id] - 1 + np.abs(rem[stor_id]) <= 1e-7 and nb_act == nb_[stor_id] - 1 ): reds[stor_id] = obs.storage_max_p_absorb[stor_id] else: @@ -1509,9 +1686,14 @@ def _aux_get_back_to_ref_state_storage( def get_back_to_ref_state( self, obs: "grid2op.Observation.BaseObservation", - storage_setpoint=0.5, - precision=5, - ) -> Dict[str, List[BaseAction]]: + storage_setpoint: float=0.5, + precision: int=5, + ) -> Dict[Literal["powerline", + "substation", + "redispatching", + "storage", + "curtailment"], + List[BaseAction]]: """ This function returns the list of unary actions that you can perform in order to get back to the "fully meshed" / "initial" topology. @@ -1525,8 +1707,8 @@ def get_back_to_ref_state( - an action that acts on a single powerline - an action on a single substation - - a redispatching action - - a storage action + - a redispatching action (acting possibly on all generators) + - a storage action (acting possibly on all generators) The list might be relatively long, in the case where lots of actions are needed. Depending on the rules of the game (for example limiting the action on one single substation), in order to get back to this topology, multiple consecutive actions will need to be implemented. @@ -1536,7 +1718,7 @@ def get_back_to_ref_state( - "powerline" for the list of actions needed to set back the powerlines in a proper state (connected). They can be of type "change_line" or "set_line". - "substation" for the list of actions needed to set back each substation in its initial state (everything connected to bus 1). They can be implemented as "set_bus" or "change_bus" - - "redispatching": for the redispatching action (there can be multiple redispatching actions needed because of the ramps of the generator) + - "redispatching": for the redispatching actions (there can be multiple redispatching actions needed because of the ramps of the generator) - "storage": for action on storage units (you might need to perform multiple storage actions because of the maximum power these units can absorb / produce ) - "curtailment": for curtailment action (usually at most one such action is needed) @@ -1564,7 +1746,22 @@ def get_back_to_ref_state( Examples -------- - TODO + You can use it like this: + + .. code-block:: python + + import grid2op + + env_name = "l2rpn_case14_sandbox" + env = grid2op.make(env_name) + obs = env.reset(seed=1) + + # perform a random action + obs, reward, done, info = env.step(env.action_space.sample()) + assert not done # you might end up in a "done" state depending on the random action + + acts = obs.get_back_to_ref_state() + print(acts) """ from grid2op.Observation.baseObservation import BaseObservation @@ -1574,7 +1771,6 @@ def get_back_to_ref_state( "You need to provide a grid2op Observation for this function to work correctly." ) res = {} - # powerline actions self._aux_get_back_to_ref_state_line(res, obs) # substations diff --git a/grid2op/Agent/recoPowerLinePerArea.py b/grid2op/Agent/recoPowerLinePerArea.py index bc28584e1..e6142124c 100644 --- a/grid2op/Agent/recoPowerLinePerArea.py +++ b/grid2op/Agent/recoPowerLinePerArea.py @@ -57,7 +57,7 @@ def act(self, observation: BaseObservation, reward: float, done : bool=False): return self.action_space() area_used = np.full(self.nb_area, fill_value=False, dtype=bool) reco_ids = [] - for l_id in np.where(can_be_reco)[0]: + for l_id in np.nonzero(can_be_reco)[0]: if not area_used[self.lines_to_area_id[l_id]]: reco_ids.append(l_id) area_used[self.lines_to_area_id[l_id]] = True diff --git a/grid2op/Agent/recoPowerlineAgent.py b/grid2op/Agent/recoPowerlineAgent.py index b4373f9bd..97ba1ed36 100644 --- a/grid2op/Agent/recoPowerlineAgent.py +++ b/grid2op/Agent/recoPowerlineAgent.py @@ -28,6 +28,6 @@ def _get_tested_action(self, observation): if can_be_reco.any(): res = [ self.action_space({"set_line_status": [(id_, +1)]}) - for id_ in np.where(can_be_reco)[0] + for id_ in np.nonzero(can_be_reco)[0] ] return res diff --git a/grid2op/Backend/backend.py b/grid2op/Backend/backend.py index a06fc00b0..820f41e80 100644 --- a/grid2op/Backend/backend.py +++ b/grid2op/Backend/backend.py @@ -33,7 +33,7 @@ DivergingPowerflow, Grid2OpException, ) -from grid2op.Space import GridObjects +from grid2op.Space import GridObjects, DEFAULT_N_BUSBAR_PER_SUB # TODO method to get V and theta at each bus, could be in the same shape as check_kirchoff @@ -171,6 +171,82 @@ def __init__(self, for k, v in kwargs.items(): self._my_kwargs[k] = v + #: .. versionadded:: 1.10.0 + #: + #: A flag to indicate whether the :func:`Backend.cannot_handle_more_than_2_busbar` + #: or the :func:`Backend.cannot_handle_more_than_2_busbar` + #: has been called when :func:`Backend.load_grid` was called. + #: Starting from grid2op 1.10.0 this is a requirement (to + #: ensure backward compatibility) + self._missing_two_busbars_support_info: bool = True + + #: .. versionadded:: 1.10.0 + #: + #: 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 + + def can_handle_more_than_2_busbar(self): + """ + .. versionadded:: 1.10.0 + + This function should be called once in :func:`Backend.load_grid` if your backend is able + to handle more than 2 busbars per substation. + + If not called, then the `environment` will not be able to use more than 2 busbars per substations. + + .. seealso:: + :func:`Backend.cannot_handle_more_than_2_busbar` + + .. note:: + From grid2op 1.10.0 it is preferable that your backend calls one of + :func:`Backend.can_handle_more_than_2_busbar` or + :func:`Backend.cannot_handle_more_than_2_busbar`. + + If not, then the environments created with your backend will not be able to + "operate" grid with more than 2 busbars per substation. + + .. danger:: + We highly recommend you do not try to override this function. + + At least, at time of writing I can't find any good reason to do so. + """ + self._missing_two_busbars_support_info = False + self.n_busbar_per_sub = type(self).n_busbar_per_sub + + def cannot_handle_more_than_2_busbar(self): + """ + .. versionadded:: 1.10.0 + + This function should be called once in :func:`Backend.load_grid` if your backend is **NOT** able + to handle more than 2 busbars per substation. + + If not called, then the `environment` will not be able to use more than 2 busbars per substations. + + .. seealso:: + :func:`Backend.cnot_handle_more_than_2_busbar` + + .. note:: + From grid2op 1.10.0 it is preferable that your backend calls one of + :func:`Backend.can_handle_more_than_2_busbar` or + :func:`Backend.cannot_handle_more_than_2_busbar`. + + If not, then the environments created with your backend will not be able to + "operate" grid with more than 2 busbars per substation. + + .. danger:: + We highly recommend you do not try to override this function. + + Atleast, at time of writing I can't find any good reason to do so. + """ + self._missing_two_busbars_support_info = False + if type(self).n_busbar_per_sub != DEFAULT_N_BUSBAR_PER_SUB: + warnings.warn("You asked in `make` function to have more than 2 busbar per substation. It is " + f"not possible with a backend of type {type(self)}. To " + "'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 make_complete_path(self, path : Union[os.PathLike, str], filename : Optional[Union[os.PathLike, str]]=None) -> str: @@ -420,7 +496,7 @@ def lines_or_info(self) -> Tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray] .. note:: It is called after the solver has been ran, only in case of success (convergence). - It returns the information extracted from the _grid at the origin end of each powerline. + It returns the information extracted from the _grid at the origin side of each powerline. For assumption about the order of the powerline flows return in this vector, see the help of the :func:`Backend.get_line_status` method. @@ -453,7 +529,7 @@ def lines_ex_info(self) -> Tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray] .. note:: It is called after the solver has been ran, only in case of success (convergence). - It returns the information extracted from the _grid at the extremity end of each powerline. + It returns the information extracted from the _grid at the extremity side of each powerline. For assumption about the order of the powerline flows return in this vector, see the help of the :func:`Backend.get_line_status` method. @@ -610,10 +686,10 @@ def get_line_flow(self) -> np.ndarray: It is called after the solver has been ran, only in case of success (convergence). If the AC mod is used, this shall return the current flow on the end of the powerline where there is a protection. - For example, if there is a protection on "origin end" of powerline "l2" then this method shall return the current - flow of at the "origin end" of powerline l2. + For example, if there is a protection on "origin side" of powerline "l2" then this method shall return the current + flow of at the "origin side" of powerline l2. - Note that in general, there is no loss of generality in supposing all protections are set on the "origin end" of + Note that in general, there is no loss of generality in supposing all protections are set on the "origin side" of the powerline. So this method will return all origin line flows. It is also possible, for a specific application, to return the maximum current flow between both ends of a power _grid for more complex scenario. @@ -673,11 +749,11 @@ def set_thermal_limit(self, limits : Union[np.ndarray, Dict["str", float]]) -> N if el in limits: try: tmp = dt_float(limits[el]) - except: + except Exception as exc_: raise BackendError( 'Impossible to convert data ({}) for powerline named "{}" into float ' "values".format(limits[el], el) - ) + ) from exc_ if tmp <= 0: raise BackendError( 'New thermal limit for powerlines "{}" is not positive ({})' @@ -949,11 +1025,11 @@ def _runpf_with_diverging_exception(self, is_dc : bool) -> Optional[Exception]: conv, exc_me = self.runpf(is_dc=is_dc) # run powerflow except Grid2OpException as exc_: exc_me = exc_ - except Exception as exc_: - exc_me = DivergingPowerflow( - f" An unexpected error occurred during the computation of the powerflow." - f"The error is: \n {exc_} \n. This is game over" - ) + # except Exception as exc_: + # exc_me = DivergingPowerflow( + # f" An unexpected error occurred during the computation of the powerflow." + # f"The error is: \n {exc_} \n. This is game over" + # ) if not conv and exc_me is None: exc_me = DivergingPowerflow( @@ -1135,10 +1211,10 @@ def check_kirchoff(self) -> Tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray q_subs = np.zeros(cls.n_sub, dtype=dt_float) # check for each bus - p_bus = np.zeros((cls.n_sub, 2), dtype=dt_float) - q_bus = np.zeros((cls.n_sub, 2), dtype=dt_float) + 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, 2, 2), dtype=dt_float) - 1.0 + 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() @@ -1171,28 +1247,30 @@ def check_kirchoff(self) -> Tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray q_bus[sub_ex_id, loc_bus_ex] += q_ex[i] # fill the min / max voltage per bus (initialization) - if (v_bus[sub_or_id,loc_bus_or,][0] == -1): - v_bus[sub_or_id,loc_bus_or,][0] = v_or[i] - if (v_bus[sub_ex_id,loc_bus_ex,][0] == -1): - v_bus[sub_ex_id,loc_bus_ex,][0] = v_ex[i] + if (v_bus[sub_or_id, loc_bus_or,][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] + v_bus[sub_or_id, loc_bus_or,][1] = v_or[i] + if (v_bus[sub_ex_id, loc_bus_ex,][1]== -1): + v_bus[sub_ex_id, loc_bus_ex,][1] = v_ex[i] # now compute the correct stuff if v_or[i] > 0.0: # line is connected - v_bus[sub_or_id,loc_bus_or,][0] = min(v_bus[sub_or_id,loc_bus_or,][0],v_or[i],) - v_bus[sub_or_id,loc_bus_or,][1] = max(v_bus[sub_or_id,loc_bus_or,][1],v_or[i],) + v_bus[sub_or_id, loc_bus_or,][0] = min(v_bus[sub_or_id, loc_bus_or,][0],v_or[i],) + v_bus[sub_or_id, loc_bus_or,][1] = max(v_bus[sub_or_id, loc_bus_or,][1],v_or[i],) if v_ex[i] > 0: # line is connected - v_bus[sub_ex_id,loc_bus_ex,][0] = min(v_bus[sub_ex_id,loc_bus_ex,][0],v_ex[i],) - v_bus[sub_ex_id,loc_bus_ex,][1] = max(v_bus[sub_ex_id,loc_bus_ex,][1],v_ex[i],) + v_bus[sub_ex_id, loc_bus_ex,][0] = min(v_bus[sub_ex_id, loc_bus_ex,][0],v_ex[i],) + v_bus[sub_ex_id, loc_bus_ex,][1] = max(v_bus[sub_ex_id, loc_bus_ex,][1],v_ex[i],) for i in range(cls.n_gen): - if topo_vect[cls.gen_pos_topo_vect[i]] == -1: + gptv = cls.gen_pos_topo_vect[i] + + if topo_vect[gptv] == -1: # gen is disconnected continue @@ -1200,38 +1278,42 @@ def check_kirchoff(self) -> Tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray p_subs[cls.gen_to_subid[i]] -= p_gen[i] q_subs[cls.gen_to_subid[i]] -= q_gen[i] + loc_bus = topo_vect[gptv] - 1 # for bus p_bus[ - cls.gen_to_subid[i], topo_vect[cls.gen_pos_topo_vect[i]] - 1 + cls.gen_to_subid[i], loc_bus ] -= p_gen[i] q_bus[ - cls.gen_to_subid[i], topo_vect[cls.gen_pos_topo_vect[i]] - 1 + cls.gen_to_subid[i], loc_bus ] -= q_gen[i] # compute max and min values if v_gen[i]: # but only if gen is connected - v_bus[cls.gen_to_subid[i], topo_vect[cls.gen_pos_topo_vect[i]] - 1][ + v_bus[cls.gen_to_subid[i], loc_bus][ 0 ] = min( v_bus[ - cls.gen_to_subid[i], topo_vect[cls.gen_pos_topo_vect[i]] - 1 + cls.gen_to_subid[i], loc_bus ][0], v_gen[i], ) - v_bus[cls.gen_to_subid[i], topo_vect[cls.gen_pos_topo_vect[i]] - 1][ + v_bus[cls.gen_to_subid[i], loc_bus][ 1 ] = max( v_bus[ - cls.gen_to_subid[i], topo_vect[cls.gen_pos_topo_vect[i]] - 1 + cls.gen_to_subid[i], loc_bus ][1], v_gen[i], ) for i in range(cls.n_load): - if topo_vect[cls.load_pos_topo_vect[i]] == -1: + gptv = cls.load_pos_topo_vect[i] + + if topo_vect[gptv] == -1: # load is disconnected continue + loc_bus = topo_vect[gptv] - 1 # for substations p_subs[cls.load_to_subid[i]] += p_load[i] @@ -1239,44 +1321,46 @@ def check_kirchoff(self) -> Tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray # for buses p_bus[ - cls.load_to_subid[i], topo_vect[cls.load_pos_topo_vect[i]] - 1 + cls.load_to_subid[i], loc_bus ] += p_load[i] q_bus[ - cls.load_to_subid[i], topo_vect[cls.load_pos_topo_vect[i]] - 1 + cls.load_to_subid[i], loc_bus ] += q_load[i] # compute max and min values if v_load[i]: # but only if load is connected - v_bus[cls.load_to_subid[i], topo_vect[cls.load_pos_topo_vect[i]] - 1][ + v_bus[cls.load_to_subid[i], loc_bus][ 0 ] = min( v_bus[ - cls.load_to_subid[i], topo_vect[cls.load_pos_topo_vect[i]] - 1 + cls.load_to_subid[i], loc_bus ][0], v_load[i], ) - v_bus[cls.load_to_subid[i], topo_vect[cls.load_pos_topo_vect[i]] - 1][ + v_bus[cls.load_to_subid[i], loc_bus][ 1 ] = max( v_bus[ - cls.load_to_subid[i], topo_vect[cls.load_pos_topo_vect[i]] - 1 + cls.load_to_subid[i], loc_bus ][1], v_load[i], ) for i in range(cls.n_storage): - if topo_vect[cls.storage_pos_topo_vect[i]] == -1: + gptv = cls.storage_pos_topo_vect[i] + if topo_vect[gptv] == -1: # storage is disconnected continue + loc_bus = topo_vect[gptv] - 1 p_subs[cls.storage_to_subid[i]] += p_storage[i] q_subs[cls.storage_to_subid[i]] += q_storage[i] p_bus[ - cls.storage_to_subid[i], topo_vect[cls.storage_pos_topo_vect[i]] - 1 + cls.storage_to_subid[i], loc_bus ] += p_storage[i] q_bus[ - cls.storage_to_subid[i], topo_vect[cls.storage_pos_topo_vect[i]] - 1 + cls.storage_to_subid[i], loc_bus ] += q_storage[i] # compute max and min values @@ -1284,21 +1368,21 @@ def check_kirchoff(self) -> Tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray # the storage unit is connected v_bus[ cls.storage_to_subid[i], - topo_vect[cls.storage_pos_topo_vect[i]] - 1, + loc_bus, ][0] = min( v_bus[ cls.storage_to_subid[i], - topo_vect[cls.storage_pos_topo_vect[i]] - 1, + loc_bus, ][0], v_storage[i], ) v_bus[ self.storage_to_subid[i], - topo_vect[self.storage_pos_topo_vect[i]] - 1, + loc_bus, ][1] = max( v_bus[ cls.storage_to_subid[i], - topo_vect[cls.storage_pos_topo_vect[i]] - 1, + loc_bus, ][1], v_storage[i], ) @@ -1330,7 +1414,7 @@ def check_kirchoff(self) -> Tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray "Backend.check_kirchoff Impossible to get shunt information. Reactive information might be " "incorrect." ) - diff_v_bus = np.zeros((self.n_sub, 2), dtype=dt_float) + 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 @@ -1854,11 +1938,28 @@ def assert_grid_correct(self) -> None: .. warning:: /!\\\\ Internal, do not use unless you know what you are doing /!\\\\ This is done as it should be by the Environment + """ # lazy loading from grid2op.Action import CompleteAction from grid2op.Action._backendAction import _BackendAction + if self._missing_two_busbars_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.10.0: the possibility " + "to have more than 2 busbars per substations (or not). " + "To silence this warning, you can modify the `load_grid` implementation " + "of your backend and either call:\n" + "- self.can_handle_more_than_2_busbar if the current implementation " + " can handle more than 2 busbsars OR\n" + "- self.cannot_handle_more_than_2_busbar if not." + "\nAnd of course, ideally, if the current implementation " + "of your backend cannot " + "handle more than 2 busbars per substation, then change it :-)\n" + "Your backend will behave as if it did not support it.") + self._missing_two_busbars_support_info = False + self.n_busbar_per_sub = DEFAULT_N_BUSBAR_PER_SUB + orig_type = type(self) if orig_type.my_bk_act_class is None: # class is already initialized diff --git a/grid2op/Backend/educPandaPowerBackend.py b/grid2op/Backend/educPandaPowerBackend.py index 6caf2f039..ec045736d 100644 --- a/grid2op/Backend/educPandaPowerBackend.py +++ b/grid2op/Backend/educPandaPowerBackend.py @@ -131,7 +131,8 @@ def load_grid(self, example. (But of course you can still use switches if you really want to) """ - + self.cannot_handle_more_than_2_busbar() + # first, handles different kind of path: full_path = self.make_complete_path(path, filename) diff --git a/grid2op/Backend/pandaPowerBackend.py b/grid2op/Backend/pandaPowerBackend.py index e4b9c0ccf..0cb000c36 100644 --- a/grid2op/Backend/pandaPowerBackend.py +++ b/grid2op/Backend/pandaPowerBackend.py @@ -18,10 +18,11 @@ import pandapower as pp import scipy +import grid2op from grid2op.dtypes import dt_int, dt_float, dt_bool -from grid2op.Backend.backend import Backend from grid2op.Action import BaseAction from grid2op.Exceptions import BackendError +from grid2op.Backend.backend import Backend try: import numba @@ -63,31 +64,31 @@ class PandaPowerBackend(Backend): The ratio that allow the conversion from pair-unit to kv for the loads lines_or_pu_to_kv: :class:`numpy.array`, dtype:float - The ratio that allow the conversion from pair-unit to kv for the origin end of the powerlines + The ratio that allow the conversion from pair-unit to kv for the origin side of the powerlines lines_ex_pu_to_kv: :class:`numpy.array`, dtype:float - The ratio that allow the conversion from pair-unit to kv for the extremity end of the powerlines + The ratio that allow the conversion from pair-unit to kv for the extremity side of the powerlines p_or: :class:`numpy.array`, dtype:float - The active power flowing at the origin end of each powerline + The active power flowing at the origin side of each powerline q_or: :class:`numpy.array`, dtype:float - The reactive power flowing at the origin end of each powerline + The reactive power flowing at the origin side of each powerline v_or: :class:`numpy.array`, dtype:float The voltage magnitude at the origin bus of the powerline a_or: :class:`numpy.array`, dtype:float - The current flowing at the origin end of each powerline + The current flowing at the origin side of each powerline p_ex: :class:`numpy.array`, dtype:float - The active power flowing at the extremity end of each powerline + The active power flowing at the extremity side of each powerline q_ex: :class:`numpy.array`, dtype:float - The reactive power flowing at the extremity end of each powerline + The reactive power flowing at the extremity side of each powerline a_ex: :class:`numpy.array`, dtype:float - The current flowing at the extremity end of each powerline + The current flowing at the extremity side of each powerline v_ex: :class:`numpy.array`, dtype:float The voltage magnitude at the extremity bus of the powerline @@ -222,6 +223,7 @@ def __init__( self._max_iter : bool = max_iter self._in_service_line_col_id = None self._in_service_trafo_col_id = None + self._in_service_storage_cold_id = None def _check_for_non_modeled_elements(self): """This function check for elements in the pandapower grid that will have no impact on grid2op. @@ -337,6 +339,7 @@ def load_grid(self, are set as "out of service" unless a topological action acts on these specific substations. """ + self.can_handle_more_than_2_busbar() full_path = self.make_complete_path(path, filename) with warnings.catch_warnings(): @@ -345,9 +348,6 @@ def load_grid(self, warnings.filterwarnings("ignore", category=FutureWarning) self._grid = pp.from_json(full_path) self._check_for_non_modeled_elements() - - self._in_service_line_col_id = int(np.where(self._grid.line.columns == "in_service")[0][0]) - self._in_service_trafo_col_id = int(np.where(self._grid.trafo.columns == "in_service")[0][0]) # add the slack bus that is often not modeled as a generator, but i need it for this backend to work bus_gen_added = None @@ -437,7 +437,7 @@ def load_grid(self, # TODO here i force the distributed slack bus too, by removing the other from the ext_grid... self._grid.ext_grid = self._grid.ext_grid.iloc[:1] else: - self.slack_id = np.where(self._grid.gen["slack"])[0] + self.slack_id = np.nonzero(self._grid.gen["slack"])[0] with warnings.catch_warnings(): warnings.filterwarnings("ignore") @@ -556,13 +556,18 @@ def load_grid(self, # "hack" to handle topological changes, for now only 2 buses per substation add_topo = copy.deepcopy(self._grid.bus) - add_topo.index += add_topo.shape[0] - add_topo["in_service"] = False - # self._grid.bus = pd.concat((self._grid.bus, add_topo)) - for ind, el in add_topo.iterrows(): - pp.create_bus(self._grid, index=ind, **el) - + # TODO n_busbar: what if non contiguous indexing ??? + for _ in range(self.n_busbar_per_sub - 1): # self.n_busbar_per_sub and not type(self) here otherwise it erases can_handle_more_than_2_busbar / cannot_handle_more_than_2_busbar + add_topo.index += add_topo.shape[0] + add_topo["in_service"] = False + for ind, el in add_topo.iterrows(): + pp.create_bus(self._grid, index=ind, **el) self._init_private_attrs() + + # do this at the end + self._in_service_line_col_id = int(np.nonzero(self._grid.line.columns == "in_service")[0][0]) + self._in_service_trafo_col_id = int(np.nonzero(self._grid.trafo.columns == "in_service")[0][0]) + self._in_service_storage_cold_id = int(np.nonzero(self._grid.storage.columns == "in_service")[0][0]) def _init_private_attrs(self) -> None: # number of elements per substation @@ -824,11 +829,12 @@ def apply_action(self, backendAction: Union["grid2op.Action._backendAction._Back ) = backendAction() # handle bus status - bus_is = self._grid.bus["in_service"] - for i, (bus1_status, bus2_status) in enumerate(active_bus): - bus_is[i] = bus1_status # no iloc for bus, don't ask me why please :-/ - bus_is[i + self.__nb_bus_before] = bus2_status - + self._grid.bus["in_service"] = pd.Series(data=active_bus.T.reshape(-1), + index=np.arange(cls.n_sub * cls.n_busbar_per_sub), + dtype=bool) + # TODO n_busbar what if index is not continuous + + # handle generators tmp_prod_p = self._get_vector_inj["prod_p"](self._grid) if (prod_p.changed).any(): tmp_prod_p.iloc[prod_p.changed] = prod_p.values[prod_p.changed] @@ -851,7 +857,7 @@ def apply_action(self, backendAction: Union["grid2op.Action._backendAction._Back if (load_q.changed).any(): tmp_load_q.iloc[load_q.changed] = load_q.values[load_q.changed] - if self.n_storage > 0: + if cls.n_storage > 0: # active setpoint tmp_stor_p = self._grid.storage["p_mw"] if (storage.changed).any(): @@ -859,19 +865,18 @@ def apply_action(self, backendAction: Union["grid2op.Action._backendAction._Back # topology of the storage stor_bus = backendAction.get_storages_bus() - new_bus_id = stor_bus.values[stor_bus.changed] # id of the busbar 1 or 2 if - activated = new_bus_id > 0 # mask of storage that have been activated - new_bus_num = ( - self.storage_to_subid[stor_bus.changed] + (new_bus_id - 1) * self.n_sub - ) # bus number - new_bus_num[~activated] = self.storage_to_subid[stor_bus.changed][ - ~activated - ] - self._grid.storage["in_service"].values[stor_bus.changed] = activated - self._grid.storage["bus"].values[stor_bus.changed] = new_bus_num - self._topo_vect[self.storage_pos_topo_vect[stor_bus.changed]] = new_bus_num + new_bus_num = dt_int(1) * self._grid.storage["bus"].values + new_bus_id = stor_bus.values[stor_bus.changed] + new_bus_num[stor_bus.changed] = cls.local_bus_to_global(new_bus_id, cls.storage_to_subid[stor_bus.changed]) + deactivated = new_bus_num <= -1 + deact_and_changed = deactivated & stor_bus.changed + new_bus_num[deact_and_changed] = cls.storage_to_subid[deact_and_changed] + 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[ - self.storage_pos_topo_vect[stor_bus.changed][~activated] + cls.storage_pos_topo_vect[deact_and_changed] ] = -1 if type(backendAction).shunts_data_available: @@ -899,7 +904,7 @@ 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) - + def _apply_load_bus(self, new_bus, id_el_backend, id_topo): new_bus_backend = type(self).local_bus_to_global_int( new_bus, self._init_bus_load[id_el_backend] @@ -990,6 +995,70 @@ def _aux_get_line_info(self, colname1, colname2): ) return res + def _aux_runpf_pp(self, is_dc: bool): + with warnings.catch_warnings(): + # remove the warning if _grid non connex. And it that case load flow as not converged + warnings.filterwarnings( + "ignore", category=scipy.sparse.linalg.MatrixRankWarning + ) + warnings.filterwarnings("ignore", category=RuntimeWarning) + warnings.filterwarnings("ignore", category=DeprecationWarning) + nb_bus = self.get_nb_active_bus() + if self._nb_bus_before is None: + self._pf_init = "dc" + elif nb_bus == self._nb_bus_before: + self._pf_init = "results" + else: + self._pf_init = "auto" + + if (~self._grid.load["in_service"]).any(): + # TODO see if there is a better way here -> do not handle this here, but rather in Backend._next_grid_state + raise pp.powerflow.LoadflowNotConverged("Disconnected load: for now grid2op cannot handle properly" + " disconnected load. If you want to disconnect one, say it" + " consumes 0. instead. Please check loads: " + f"{np.nonzero(~self._grid.load['in_service'])[0]}" + ) + if (~self._grid.gen["in_service"]).any(): + # TODO see if there is a better way here -> do not handle this here, but rather in Backend._next_grid_state + raise pp.powerflow.LoadflowNotConverged("Disconnected gen: for now grid2op cannot handle properly" + " disconnected generators. If you want to disconnect one, say it" + " produces 0. instead. Please check generators: " + f"{np.nonzero(~self._grid.gen['in_service'])[0]}" + ) + try: + if is_dc: + pp.rundcpp(self._grid, check_connectivity=True, init="flat") + # if I put check_connectivity=False then the test AAATestBackendAPI.test_22_islanded_grid_make_divergence + # does not pass + + # if dc i start normally next time i call an ac powerflow + self._nb_bus_before = None + else: + pp.runpp( + self._grid, + check_connectivity=False, + init=self._pf_init, + numba=self.with_numba, + lightsim2grid=self._lightsim2grid, + max_iteration=self._max_iter, + distributed_slack=self._dist_slack, + ) + except IndexError as exc_: + raise pp.powerflow.LoadflowNotConverged(f"Surprising behaviour of pandapower when a bus is not connected to " + f"anything but present on the bus (with check_connectivity=False). " + f"Error was {exc_}" + ) + + # stores the computation time + if "_ppc" in self._grid: + if "et" in self._grid["_ppc"]: + self.comp_time += self._grid["_ppc"]["et"] + if self._grid.res_gen.isnull().values.any(): + # TODO see if there is a better way here -> do not handle this here, but rather in Backend._next_grid_state + # sometimes pandapower does not detect divergence and put Nan. + raise pp.powerflow.LoadflowNotConverged("Divergence due to Nan values in res_gen table (most likely due to " + "a non connected grid).") + def runpf(self, is_dc : bool=False) -> Tuple[bool, Union[Exception, None]]: """ INTERNAL @@ -1001,70 +1070,10 @@ def runpf(self, is_dc : bool=False) -> Tuple[bool, Union[Exception, None]]: buses has not changed between two calls, the previous results are re used. This speeds up the computation in case of "do nothing" action applied. """ - nb_bus = self.get_nb_active_bus() try: - with warnings.catch_warnings(): - # remove the warning if _grid non connex. And it that case load flow as not converged - warnings.filterwarnings( - "ignore", category=scipy.sparse.linalg.MatrixRankWarning - ) - warnings.filterwarnings("ignore", category=RuntimeWarning) - warnings.filterwarnings("ignore", category=DeprecationWarning) - if self._nb_bus_before is None: - self._pf_init = "dc" - elif nb_bus == self._nb_bus_before: - self._pf_init = "results" - else: - self._pf_init = "auto" - - if (~self._grid.load["in_service"]).any(): - # TODO see if there is a better way here -> do not handle this here, but rather in Backend._next_grid_state - raise pp.powerflow.LoadflowNotConverged("Disconnected load: for now grid2op cannot handle properly" - " disconnected load. If you want to disconnect one, say it" - " consumes 0. instead. Please check loads: " - f"{np.where(~self._grid.load['in_service'])[0]}" - ) - if (~self._grid.gen["in_service"]).any(): - # TODO see if there is a better way here -> do not handle this here, but rather in Backend._next_grid_state - raise pp.powerflow.LoadflowNotConverged("Disconnected gen: for now grid2op cannot handle properly" - " disconnected generators. If you want to disconnect one, say it" - " produces 0. instead. Please check generators: " - f"{np.where(~self._grid.gen['in_service'])[0]}" - ) - try: - if is_dc: - pp.rundcpp(self._grid, check_connectivity=True, init="flat") - # if I put check_connectivity=False then the test AAATestBackendAPI.test_22_islanded_grid_make_divergence - # does not pass - - # if dc i start normally next time i call an ac powerflow - self._nb_bus_before = None - else: - pp.runpp( - self._grid, - check_connectivity=False, - init=self._pf_init, - numba=self.with_numba, - lightsim2grid=self._lightsim2grid, - max_iteration=self._max_iter, - distributed_slack=self._dist_slack, - ) - except IndexError as exc_: - raise pp.powerflow.LoadflowNotConverged(f"Surprising behaviour of pandapower when a bus is not connected to " - f"anything but present on the bus (with check_connectivity=False). " - f"Error was {exc_}" - ) - - # stores the computation time - if "_ppc" in self._grid: - if "et" in self._grid["_ppc"]: - self.comp_time += self._grid["_ppc"]["et"] - if self._grid.res_gen.isnull().values.any(): - # TODO see if there is a better way here -> do not handle this here, but rather in Backend._next_grid_state - # sometimes pandapower does not detect divergence and put Nan. - raise pp.powerflow.LoadflowNotConverged("Divergence due to Nan values in res_gen table (most likely due to " - "a non connected grid).") - + 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(): raise pp.powerflow.LoadflowNotConverged("Isolated bus") @@ -1094,15 +1103,15 @@ def runpf(self, is_dc : bool=False) -> Tuple[bool, Union[Exception, None]]: # need to assign the correct value when a generator is present at the same bus # TODO optimize this ugly loop # see https://github.com/e2nIEE/pandapower/issues/1996 for a fix - for l_id in range(self.n_load): - if self.load_to_subid[l_id] in self.gen_to_subid: - ind_gens = np.where( - self.gen_to_subid == self.load_to_subid[l_id] + for l_id in range(cls.n_load): + if cls.load_to_subid[l_id] in cls.gen_to_subid: + ind_gens = np.nonzero( + cls.gen_to_subid == cls.load_to_subid[l_id] )[0] for g_id in ind_gens: if ( - self._topo_vect[self.load_pos_topo_vect[l_id]] - == self._topo_vect[self.gen_pos_topo_vect[g_id]] + self._topo_vect[cls.load_pos_topo_vect[l_id]] + == self._topo_vect[cls.gen_pos_topo_vect[g_id]] ): self.load_v[l_id] = self.prod_v[g_id] break @@ -1306,7 +1315,8 @@ def copy(self) -> "PandaPowerBackend": res._in_service_line_col_id = self._in_service_line_col_id res._in_service_trafo_col_id = self._in_service_trafo_col_id - + + res._missing_two_busbars_support_info = self._missing_two_busbars_support_info return res def close(self) -> None: @@ -1379,70 +1389,29 @@ def get_topo_vect(self) -> np.ndarray: return self._topo_vect def _get_topo_vect(self): - res = np.full(self.dim_topo, fill_value=np.iinfo(dt_int).max, dtype=dt_int) + 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() - - i = 0 - for row in self._grid.line[["from_bus", "to_bus"]].values: - bus_or_id = row[0] - bus_ex_id = row[1] - if line_status[i]: - res[self.line_or_pos_topo_vect[i]] = ( - 1 if bus_or_id == self.line_or_to_subid[i] else 2 - ) - res[self.line_ex_pos_topo_vect[i]] = ( - 1 if bus_ex_id == self.line_ex_to_subid[i] else 2 - ) - else: - res[self.line_or_pos_topo_vect[i]] = -1 - res[self.line_ex_pos_topo_vect[i]] = -1 - i += 1 - - nb = self._number_true_line - i = 0 - for row in self._grid.trafo[["hv_bus", "lv_bus"]].values: - bus_or_id = row[0] - bus_ex_id = row[1] - - j = i + nb - if line_status[j]: - res[self.line_or_pos_topo_vect[j]] = ( - 1 if bus_or_id == self.line_or_to_subid[j] else 2 - ) - res[self.line_ex_pos_topo_vect[j]] = ( - 1 if bus_ex_id == self.line_ex_to_subid[j] else 2 - ) - else: - res[self.line_or_pos_topo_vect[j]] = -1 - res[self.line_ex_pos_topo_vect[j]] = -1 - i += 1 - - i = 0 - 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 - - 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 - - if self.n_storage: - # storage can be deactivated by the environment for backward compatibility - i = 0 - for bus_id in self._grid.storage["bus"].values: - status = self._grid.storage["in_service"].values[i] - if status: - res[self.storage_pos_topo_vect[i]] = ( - 1 if bus_id == self.storage_to_subid[i] else 2 - ) - else: - res[self.storage_pos_topo_vect[i]] = -1 - i += 1 - + 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 + 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 + # 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 + 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 + # 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 def _gens_info(self): diff --git a/grid2op/Chronics/GSFFWFWM.py b/grid2op/Chronics/GSFFWFWM.py index fc09e16e3..385886a34 100644 --- a/grid2op/Chronics/GSFFWFWM.py +++ b/grid2op/Chronics/GSFFWFWM.py @@ -157,7 +157,7 @@ def _fix_maintenance_format(obj_with_maintenance): ) # there are _maintenance and hazards only if the value in the file is not 0. - obj_with_maintenance.maintenance = obj_with_maintenance.maintenance != 0.0 + obj_with_maintenance.maintenance = np.abs(obj_with_maintenance.maintenance) >= 1e-7 obj_with_maintenance.maintenance = obj_with_maintenance.maintenance.astype(dt_bool) @staticmethod @@ -251,7 +251,7 @@ def _generate_matenance_static(name_line, size=n_Generated_Maintenance - maxDailyMaintenance, ) are_lines_in_maintenance[ - np.where(are_lines_in_maintenance)[0][not_chosen] + np.nonzero(are_lines_in_maintenance)[0][not_chosen] ] = False maintenance_me[ selected_rows_beg:selected_rows_end, are_lines_in_maintenance diff --git a/grid2op/Chronics/fromOneEpisodeData.py b/grid2op/Chronics/fromOneEpisodeData.py index 46e155a09..e3214b5b7 100644 --- a/grid2op/Chronics/fromOneEpisodeData.py +++ b/grid2op/Chronics/fromOneEpisodeData.py @@ -350,7 +350,6 @@ def get_id(self) -> str: else: # TODO EpisodeData.path !!! return "" - raise NotImplementedError() def shuffle(self, shuffler=None): # TODO diff --git a/grid2op/Chronics/gridStateFromFile.py b/grid2op/Chronics/gridStateFromFile.py index 1cc53a725..d9824637f 100644 --- a/grid2op/Chronics/gridStateFromFile.py +++ b/grid2op/Chronics/gridStateFromFile.py @@ -736,7 +736,7 @@ def _init_attrs( self.hazards[:, line_id] ) - self.hazards = self.hazards != 0.0 + self.hazards = np.abs(self.hazards) >= 1e-7 if maintenance is not None: self.maintenance = copy.deepcopy( maintenance.values[:, self._order_maintenance] @@ -759,7 +759,7 @@ def _init_attrs( ] = self.get_maintenance_duration_1d(self.maintenance[:, line_id]) # there are _maintenance and hazards only if the value in the file is not 0. - self.maintenance = self.maintenance != 0.0 + self.maintenance = np.abs(self.maintenance) >= 1e-7 self.maintenance = self.maintenance.astype(dt_bool) def done(self): @@ -1026,14 +1026,14 @@ def _convert_datetime(self, datetime_beg): if not isinstance(datetime_beg, datetime): try: res = datetime.strptime(datetime_beg, "%Y-%m-%d %H:%M") - except: + except Exception as exc_: try: res = datetime.strptime(datetime_beg, "%Y-%m-%d") - except: + except Exception as exc_2: raise ChronicsError( 'Impossible to convert "{}" to a valid datetime. Accepted format is ' '"%Y-%m-%d %H:%M"'.format(datetime_beg) - ) + ) from exc_2 return res def _extract_array(self, nm): diff --git a/grid2op/Chronics/gridValue.py b/grid2op/Chronics/gridValue.py index 00bc8af50..90e3227e2 100644 --- a/grid2op/Chronics/gridValue.py +++ b/grid2op/Chronics/gridValue.py @@ -288,8 +288,8 @@ def get_maintenance_time_1d(maintenance): a = np.diff(maintenance) # +1 is because numpy does the diff `t+1` - `t` so to get index of the initial array # I need to "+1" - start = np.where(a == 1)[0] + 1 # start of maintenance - end = np.where(a == -1)[0] + 1 # end of maintenance + start = np.nonzero(a == 1)[0] + 1 # start of maintenance + end = np.nonzero(a == -1)[0] + 1 # end of maintenance prev_ = 0 # it's efficient here as i do a loop only on the number of time there is a maintenance # and maintenance are quite rare @@ -362,8 +362,8 @@ def get_maintenance_duration_1d(maintenance): a = np.diff(maintenance) # +1 is because numpy does the diff `t+1` - `t` so to get index of the initial array # I need to "+1" - start = np.where(a == 1)[0] + 1 # start of maintenance - end = np.where(a == -1)[0] + 1 # end of maintenance + start = np.nonzero(a == 1)[0] + 1 # start of maintenance + end = np.nonzero(a == -1)[0] + 1 # end of maintenance prev_ = 0 # it's efficient here as i do a loop only on the number of time there is a maintenance # and maintenance are quite rare @@ -440,8 +440,8 @@ def get_hazard_duration_1d(hazard): a = np.diff(hazard) # +1 is because numpy does the diff `t+1` - `t` so to get index of the initial array # I need to "+1" - start = np.where(a == 1)[0] + 1 # start of maintenance - end = np.where(a == -1)[0] + 1 # end of maintenance + start = np.nonzero(a == 1)[0] + 1 # start of maintenance + end = np.nonzero(a == -1)[0] + 1 # end of maintenance prev_ = 0 # it's efficient here as i do a loop only on the number of time there is a maintenance # and maintenance are quite rare diff --git a/grid2op/Chronics/handlers/csvMaintenanceHandler.py b/grid2op/Chronics/handlers/csvMaintenanceHandler.py index 19d45727e..2c47c510f 100644 --- a/grid2op/Chronics/handlers/csvMaintenanceHandler.py +++ b/grid2op/Chronics/handlers/csvMaintenanceHandler.py @@ -79,7 +79,7 @@ def _init_attrs(self, array): ] = GridValue.get_maintenance_duration_1d(self.array[:, line_id]) # there are _maintenance and hazards only if the value in the file is not 0. - self.array = self.array != 0.0 + self.array = np.abs(self.array) >= 1e-7 self.array = self.array.astype(dt_bool) def load_next_maintenance(self) -> Tuple[np.ndarray, np.ndarray]: diff --git a/grid2op/Chronics/multiFolder.py b/grid2op/Chronics/multiFolder.py index f948f94ac..7ab2be644 100644 --- a/grid2op/Chronics/multiFolder.py +++ b/grid2op/Chronics/multiFolder.py @@ -352,7 +352,7 @@ def sample_next_chronics(self, probabilities=None): probabilities /= sum_prob # take one at "random" among these selected = self.space_prng.choice(self._order, p=probabilities) - id_sel = np.where(self._order == selected)[0] + id_sel = np.nonzero(self._order == selected)[0] self._prev_cache_id = selected - 1 return id_sel diff --git a/grid2op/Chronics/readPypowNetData.py b/grid2op/Chronics/readPypowNetData.py index de5589f7a..68ca46db0 100644 --- a/grid2op/Chronics/readPypowNetData.py +++ b/grid2op/Chronics/readPypowNetData.py @@ -191,8 +191,8 @@ def initialize( self.start_datetime = datetime.strptime(datetimes_.iloc[0, 0], "%Y-%b-%d") # there are maintenance and hazards only if the value in the file is not 0. - self.maintenance = self.maintenance != 0.0 - self.hazards = self.hazards != 0.0 + self.maintenance = np.abs(self.maintenance) >= 1e-7 + self.hazards = np.abs(self.hazards) >= 1e-7 self.curr_iter = 0 if self.max_iter == -1: @@ -294,9 +294,8 @@ def initialize( self.hazard_duration[:, line_id] = self.get_maintenance_duration_1d( self.hazards[:, line_id] ) - - self.maintenance_forecast = self.maintenance != 0.0 - + self.maintenance_forecast = np.abs(self.maintenance) >= 1e-7 + self.curr_iter = 0 if self.maintenance is not None: n_ = self.maintenance.shape[0] diff --git a/grid2op/Converter/BackendConverter.py b/grid2op/Converter/BackendConverter.py index 44b381a23..a6db64614 100644 --- a/grid2op/Converter/BackendConverter.py +++ b/grid2op/Converter/BackendConverter.py @@ -206,13 +206,13 @@ def _init_myself(self): == sorted(self.target_backend.name_sub) ): for id_source, nm_source in enumerate(self.source_backend.name_sub): - id_target = np.where(self.target_backend.name_sub == nm_source)[0] + id_target = np.nonzero(self.target_backend.name_sub == nm_source)[0] self._sub_tg2sr[id_source] = id_target self._sub_sr2tg[id_target] = id_source else: for id_source, nm_source in enumerate(self.source_backend.name_sub): nm_target = self.sub_source_target[nm_source] - id_target = np.where(self.target_backend.name_sub == nm_target)[0] + id_target = np.nonzero(self.target_backend.name_sub == nm_target)[0] self._sub_tg2sr[id_source] = id_target self._sub_sr2tg[id_target] = id_source @@ -300,7 +300,7 @@ def _init_myself(self): def _get_possible_target_ids(self, id_source, source_2_id_sub, target_2_id_sub, nm): id_sub_source = source_2_id_sub[id_source] id_sub_target = self._sub_tg2sr[id_sub_source] - ids_target = np.where(target_2_id_sub == id_sub_target)[0] + ids_target = np.nonzero(target_2_id_sub == id_sub_target)[0] if ids_target.shape[0] == 0: raise RuntimeError( ERROR_ELEMENT_CONNECTED.format(nm, id_sub_target, id_sub_source) @@ -346,7 +346,7 @@ def _auto_fill_vect_powerline(self): idor_sub_target = self._sub_tg2sr[idor_sub_source] idex_sub_source = source_ex_2_id_sub[id_source] idex_sub_target = self._sub_tg2sr[idex_sub_source] - ids_target = np.where( + ids_target = np.nonzero( (target_or_2_id_sub == idor_sub_target) & (target_ex_2_id_sub == idex_sub_target) )[0] diff --git a/grid2op/Converter/ConnectivityConverter.py b/grid2op/Converter/ConnectivityConverter.py index 5826c1bcc..e9864d1dd 100644 --- a/grid2op/Converter/ConnectivityConverter.py +++ b/grid2op/Converter/ConnectivityConverter.py @@ -188,11 +188,11 @@ def init_converter(self, all_actions=None, **kwargs): if nb_element < 4: continue - c_id = np.where(self.load_to_subid == sub_id)[0] - g_id = np.where(self.gen_to_subid == sub_id)[0] - lor_id = np.where(self.line_or_to_subid == sub_id)[0] - lex_id = np.where(self.line_ex_to_subid == sub_id)[0] - storage_id = np.where(self.storage_to_subid == sub_id)[0] + c_id = np.nonzero(self.load_to_subid == sub_id)[0] + g_id = np.nonzero(self.gen_to_subid == sub_id)[0] + lor_id = np.nonzero(self.line_or_to_subid == sub_id)[0] + lex_id = np.nonzero(self.line_ex_to_subid == sub_id)[0] + storage_id = np.nonzero(self.storage_to_subid == sub_id)[0] c_pos = self.load_to_sub_pos[self.load_to_subid == sub_id] g_pos = self.gen_to_sub_pos[self.gen_to_subid == sub_id] @@ -380,20 +380,20 @@ def convert_act(self, encoded_act, explore=None): ) if ((encoded_act < -1.0) | (encoded_act > 1.0)).any(): errors = (encoded_act < -1.0) | (encoded_act > 1.0) - indexes = np.where(errors)[0] + indexes = np.nonzero(errors)[0] raise RuntimeError( f'All elements of "encoded_act" must be in range [-1, 1]. Please check your ' f"encoded action at positions {indexes[:5]}... (only first 5 displayed)" ) - act_want_change = encoded_act != 0.0 + act_want_change = np.abs(encoded_act) >= 1e-7 encoded_act_filtered = encoded_act[act_want_change] if encoded_act_filtered.shape[0] == 0: # do nothing action in this case return super().__call__() argsort_changed = np.argsort(-np.abs(encoded_act_filtered)) - argsort = np.where(act_want_change)[0][argsort_changed] + argsort = np.nonzero(act_want_change)[0][argsort_changed] act, disag = self._aux_act_from_order(argsort, encoded_act) self.indx_sel = 0 if explore is None: @@ -489,7 +489,7 @@ def _compute_disagreement(self, encoded_act, topo_vect): Lower disagreement is always better. """ - set_component = encoded_act != 0.0 + set_component = np.abs(encoded_act) >= 1e-7 bus_el1 = topo_vect[self.pos_topo[:, 0]] bus_el2 = topo_vect[self.pos_topo[:, 1]] # for the element that will connected diff --git a/grid2op/Converter/IdToAct.py b/grid2op/Converter/IdToAct.py index c1ffd241b..be96e992d 100644 --- a/grid2op/Converter/IdToAct.py +++ b/grid2op/Converter/IdToAct.py @@ -26,7 +26,7 @@ class IdToAct(Converter): A "unary action" is an action that consists only in acting on one "concept" it includes: - disconnecting a single powerline - - reconnecting a single powerline and connect it to bus xxx on its origin end and yyy on its extremity end + - reconnecting a single powerline and connect it to bus xxx on its origin side and yyy on its extremity side - changing the topology of a single substation - performing redispatching on a single generator - performing curtailment on a single generator diff --git a/grid2op/Environment/baseEnv.py b/grid2op/Environment/baseEnv.py index 613e3e409..6670a736b 100644 --- a/grid2op/Environment/baseEnv.py +++ b/grid2op/Environment/baseEnv.py @@ -13,12 +13,7 @@ import copy import os import json -from typing import Optional, Tuple, Union, Dict, Any -try: - # Literal introduced in python 3.9 - from typing import Literal -except ImportError: - from typing_extensions import Literal +from typing import Optional, Tuple, Union, Dict, Any, Literal import warnings import numpy as np @@ -36,7 +31,8 @@ InvalidRedispatching, GeneratorTurnedOffTooSoon, GeneratorTurnedOnTooSoon, - AmbiguousActionRaiseAlert) + AmbiguousActionRaiseAlert, + ImpossibleTopology) from grid2op.Parameters import Parameters from grid2op.Reward import BaseReward, RewardHelper from grid2op.Opponent import OpponentSpace, NeverAttackBudget, BaseOpponent @@ -45,7 +41,7 @@ from grid2op.Action._backendAction import _BackendAction from grid2op.Chronics import ChronicsHandler from grid2op.Rules import AlwaysLegal, BaseRules, AlwaysLegal - +from grid2op.typing_variables import STEP_INFO_TYPING, RESET_OPTIONS_TYPING # TODO put in a separate class the redispatching function @@ -80,7 +76,6 @@ # WE DO NOT RECOMMEND TO ALTER IT IN ANY WAY """ - class BaseEnv(GridObjects, RandomObject, ABC): """ INTERNAL @@ -335,11 +330,13 @@ 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, _is_test: bool = False, # TODO not implemented !! _init_obs: Optional[BaseObservation] =None ): GridObjects.__init__(self) RandomObject.__init__(self) + self._n_busbar = n_busbar # env attribute not class attribute ! if other_rewards is None: other_rewards = {} if kwargs_attention_budget is None: @@ -521,11 +518,11 @@ def __init__( self._voltage_controler = None # backend action - self._backend_action_class = None - self._backend_action = None + self._backend_action_class : type = None + self._backend_action : _BackendAction = None # specific to Basic Env, do not change - self.backend :Backend = None + self.backend : Backend = None self.__is_init = False self.debug_dispatch = False @@ -630,7 +627,8 @@ def _custom_deepcopy_for_copy(self, new_obj, dict_=None): RandomObject._custom_deepcopy_for_copy(self, new_obj) if dict_ is None: dict_ = {} - + new_obj._n_busbar = self._n_busbar + new_obj._init_grid_path = copy.deepcopy(self._init_grid_path) new_obj._init_env_path = copy.deepcopy(self._init_env_path) @@ -1023,7 +1021,7 @@ def load_alert_data(self): alertable_line_names = copy.deepcopy(lines_attacked) alertable_line_ids = np.empty(len(alertable_line_names), dtype=dt_int) for i, el in enumerate(alertable_line_names): - indx = np.where(self.backend.name_line == el)[0] + indx = np.nonzero(self.backend.name_line == el)[0] if not len(indx): raise Grid2OpException(f"Attacked line {el} is not found in the grid.") alertable_line_ids[i] = indx[0] @@ -1363,7 +1361,7 @@ def set_id(self, id_: Union[int, str]) -> None: def reset(self, *, seed: Union[int, None] = None, - options: Union[Dict[Literal["time serie id"], Union[int, str]], None] = None): + options: RESET_OPTIONS_TYPING = None): """ Reset the base environment (set the appropriate variables to correct initialization). It is (and must be) overloaded in other :class:`grid2op.Environment` @@ -1752,7 +1750,7 @@ def set_thermal_limit(self, thermal_limit): f"names. We found: {key} which is not a line name. The names of the " f"powerlines are {self.name_line}" ) - ind_line = np.where(self.name_line == key)[0][0] + ind_line = np.nonzero(self.name_line == key)[0][0] if np.isfinite(tmp[ind_line]): raise Grid2OpException( f"Humm, there is a really strange bug, some lines are set twice." @@ -1848,9 +1846,9 @@ def _prepare_redisp(self, action, new_p, already_modified_gen): redisp_act_orig = 1.0 * action._redispatch if ( - np.all(redisp_act_orig == 0.0) - and np.all(self._target_dispatch == 0.0) - and np.all(self._actual_dispatch == 0.0) + np.all(np.abs(redisp_act_orig) <= 1e-7) + and np.all(np.abs(self._target_dispatch) <= 1e-7) + and np.all(np.abs(self._actual_dispatch) <= 1e-7) ): return valid, except_, info_ # check that everything is consistent with pmin, pmax: @@ -1862,7 +1860,7 @@ def _prepare_redisp(self, action, new_p, already_modified_gen): "invalid because, even if the sepoint is pmin, this dispatch would set it " "to a number higher than pmax, which is impossible]. Invalid dispatch for " "generator(s): " - "{}".format(np.where(cond_invalid)[0]) + "{}".format(np.nonzero(cond_invalid)[0]) ) self._target_dispatch -= redisp_act_orig return valid, except_, info_ @@ -1874,13 +1872,13 @@ def _prepare_redisp(self, action, new_p, already_modified_gen): "invalid because, even if the sepoint is pmax, this dispatch would set it " "to a number bellow pmin, which is impossible]. Invalid dispatch for " "generator(s): " - "{}".format(np.where(cond_invalid)[0]) + "{}".format(np.nonzero(cond_invalid)[0]) ) self._target_dispatch -= redisp_act_orig return valid, except_, info_ # i can't redispatch turned off generators [turned off generators need to be turned on before redispatching] - if (redisp_act_orig[new_p == 0.0]).any() and self._forbid_dispatch_off: + if (redisp_act_orig[np.abs(new_p) <= 1e-7]).any() and self._forbid_dispatch_off: # action is invalid, a generator has been redispatched, but it's turned off except_ = InvalidRedispatching( "Impossible to dispatch a turned off generator" @@ -1890,11 +1888,11 @@ def _prepare_redisp(self, action, new_p, already_modified_gen): if self._forbid_dispatch_off is True: redisp_act_orig_cut = 1.0 * redisp_act_orig - redisp_act_orig_cut[new_p == 0.0] = 0.0 + redisp_act_orig_cut[np.abs(new_p) <= 1e-7] = 0.0 if (redisp_act_orig_cut != redisp_act_orig).any(): info_.append( { - "INFO: redispatching cut because generator will be turned_off": np.where( + "INFO: redispatching cut because generator will be turned_off": np.nonzero( redisp_act_orig_cut != redisp_act_orig )[ 0 @@ -1925,7 +1923,7 @@ def _compute_dispatch_vect(self, already_modified_gen, new_p): # these are the generators that will be adjusted for redispatching gen_participating = ( (new_p > 0.0) - | (self._actual_dispatch != 0.0) + | (np.abs(self._actual_dispatch) >= 1e-7) | (self._target_dispatch != self._actual_dispatch) ) gen_participating[~self.gen_redispatchable] = False @@ -2076,8 +2074,8 @@ def _compute_dispatch_vect(self, already_modified_gen, new_p): # the idea here is to chose a initial point that would be close to the # desired solution (split the (sum of the) dispatch to the available generators) x0 = np.zeros(gen_participating.sum()) - if (self._target_dispatch != 0.).any() or already_modified_gen.any(): - gen_for_x0 = self._target_dispatch[gen_participating] != 0. + if (np.abs(self._target_dispatch) >= 1e-7).any() or already_modified_gen.any(): + gen_for_x0 = np.abs(self._target_dispatch[gen_participating]) >= 1e-7 gen_for_x0 |= already_modified_gen[gen_participating] x0[gen_for_x0] = ( self._target_dispatch[gen_participating][gen_for_x0] @@ -2089,7 +2087,7 @@ def _compute_dispatch_vect(self, already_modified_gen, new_p): # in this "if" block I set the other component of x0 to # their "right" value - can_adjust = (x0 == 0.0) + can_adjust = (np.abs(x0) <= 1e-7) if can_adjust.any(): init_sum = x0.sum() denom_adjust = (1.0 / weights[can_adjust]).sum() @@ -2354,8 +2352,8 @@ def _handle_updown_times(self, gen_up_before, redisp_act): self._gen_downtime[gen_connected_this_timestep] < self.gen_min_downtime[gen_connected_this_timestep] ) - id_gen = np.where(id_gen)[0] - id_gen = np.where(gen_connected_this_timestep[id_gen])[0] + id_gen = np.nonzero(id_gen)[0] + id_gen = np.nonzero(gen_connected_this_timestep[id_gen])[0] except_ = GeneratorTurnedOnTooSoon( "Some generator has been connected too early ({})".format(id_gen) ) @@ -2376,8 +2374,8 @@ def _handle_updown_times(self, gen_up_before, redisp_act): self._gen_uptime[gen_disconnected_this] < self.gen_min_uptime[gen_disconnected_this] ) - id_gen = np.where(id_gen)[0] - id_gen = np.where(gen_connected_this_timestep[id_gen])[0] + id_gen = np.nonzero(id_gen)[0] + id_gen = np.nonzero(gen_connected_this_timestep[id_gen])[0] except_ = GeneratorTurnedOffTooSoon( "Some generator has been disconnected too early ({})".format(id_gen) ) @@ -2526,7 +2524,7 @@ def _aux_remove_power_too_low(self, delta_, indx_too_low): def _compute_storage(self, action_storage_power): self._storage_previous_charge[:] = self._storage_current_charge - storage_act = np.isfinite(action_storage_power) & (action_storage_power != 0.0) + storage_act = np.isfinite(action_storage_power) & (np.abs(action_storage_power) >= 1e-7) self._action_storage[:] = 0.0 self._storage_power[:] = 0.0 modif = False @@ -2647,7 +2645,7 @@ def _aux_update_curtailment_act(self, action): def _aux_compute_new_p_curtailment(self, new_p, curtailment_vect): """modifies the new_p argument !!!!""" gen_curtailed = ( - curtailment_vect != 1.0 + np.abs(curtailment_vect - 1.) >= 1e-7 ) # curtailed either right now, or in a previous action max_action = self.gen_pmax[gen_curtailed] * curtailment_vect[gen_curtailed] new_p[gen_curtailed] = np.minimum(max_action, new_p[gen_curtailed]) @@ -2656,7 +2654,7 @@ def _aux_compute_new_p_curtailment(self, new_p, curtailment_vect): def _aux_handle_curtailment_without_limit(self, action, new_p): """modifies the new_p argument !!!! (but not the action)""" if self.redispatching_unit_commitment_availble and ( - action._modif_curtailment or (self._limit_curtailment != 1.0).any() + action._modif_curtailment or (np.abs(self._limit_curtailment - 1.) >= 1e-7).any() ): self._aux_update_curtailment_act(action) @@ -2677,7 +2675,7 @@ def _aux_handle_curtailment_without_limit(self, action, new_p): else: self._sum_curtailment_mw = -self._sum_curtailment_mw_prev self._sum_curtailment_mw_prev = dt_float(0.0) - gen_curtailed = self._limit_curtailment != 1.0 + gen_curtailed = np.abs(self._limit_curtailment - 1.) >= 1e-7 return gen_curtailed @@ -2946,10 +2944,14 @@ def _aux_apply_redisp(self, action, new_p, new_p_th, gen_curtailed, except_): res_action = action return res_action, is_illegal_redisp, is_illegal_reco, is_done - def _aux_update_backend_action(self, action, action_storage_power, init_disp): + def _aux_update_backend_action(self, + action: BaseAction, + action_storage_power: np.ndarray, + init_disp: np.ndarray): # make sure the dispatching action is not implemented "as is" by the backend. # the environment must make sure it's a zero-sum action. # same kind of limit for the storage + res_exc_ = None action._redispatch[:] = 0.0 action._storage_power[:] = self._storage_power self._backend_action += action @@ -2958,6 +2960,7 @@ def _aux_update_backend_action(self, 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) + return res_exc_ def _update_alert_properties(self, action, lines_attacked, subs_attacked): # update the environment with the alert information from the @@ -3095,7 +3098,10 @@ def _aux_run_pf_after_state_properly_set( ) return detailed_info, has_error - def step(self, action: BaseAction) -> Tuple[BaseObservation, float, bool, dict]: + 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()` @@ -3227,6 +3233,7 @@ def step(self, action: BaseAction) -> Tuple[BaseObservation, float, bool, dict]: beg_step = time.perf_counter() self._last_obs : Optional[BaseObservation] = None self._forecasts = None # force reading the forecast from the time series + cls = type(self) try: beg_ = time.perf_counter() @@ -3240,12 +3247,12 @@ def step(self, action: BaseAction) -> Tuple[BaseObservation, float, bool, dict]: ) # battery information is_ambiguous = True - if type(self).dim_alerts > 0: + if cls.dim_alerts > 0: # keep the alert even if the rest is ambiguous (if alert is non ambiguous) is_ambiguous_alert = isinstance(except_tmp, AmbiguousActionRaiseAlert) if is_ambiguous_alert: # reset the alert - init_alert = np.zeros(type(self).dim_alerts, dtype=dt_bool) + init_alert = np.zeros(cls.dim_alerts, dtype=dt_bool) else: action.raise_alert = init_alert except_.append(except_tmp) @@ -3259,13 +3266,13 @@ def step(self, action: BaseAction) -> Tuple[BaseObservation, float, bool, dict]: 1.0 * action._storage_power ) # battery information except_.append(reason) - if type(self).dim_alerts > 0: + if cls.dim_alerts > 0: # keep the alert even if the rest is illegal action.raise_alert = init_alert is_illegal = True if self._has_attention_budget: - if type(self).assistant_warning_type == "zonal": + if cls.assistant_warning_type == "zonal": # this feature is implemented, so i do it reason_alarm_illegal = self._attention_budget.register_action( self, action, is_illegal, is_ambiguous @@ -3281,7 +3288,7 @@ def step(self, action: BaseAction) -> Tuple[BaseObservation, float, bool, dict]: new_p_th = 1.0 * new_p # storage unit - if self.n_storage > 0: + if cls.n_storage > 0: # limiting the storage units is done in `_aux_apply_redisp` # this only ensure the Emin / Emax and all the actions self._compute_storage(action_storage_power) @@ -3292,7 +3299,7 @@ def step(self, action: BaseAction) -> Tuple[BaseObservation, float, bool, dict]: gen_curtailed = self._aux_handle_curtailment_without_limit(action, new_p) beg__redisp = time.perf_counter() - if self.redispatching_unit_commitment_availble or self.n_storage > 0.0: + if cls.redispatching_unit_commitment_availble or cls.n_storage > 0.0: # this computes the "optimal" redispatching # and it is also in this function that the limiting of the curtailment / storage actions # is perform to make the state "feasible" @@ -3318,16 +3325,23 @@ def step(self, action: BaseAction) -> Tuple[BaseObservation, float, bool, dict]: tock = time.perf_counter() self._time_opponent += tock - tick self._time_create_bk_act += tock - beg_ - - self.backend.apply_action(self._backend_action) + try: + self.backend.apply_action(self._backend_action) + except ImpossibleTopology as exc_: + has_error = True + except_.append(exc_) + is_done = True + # TODO in this case: cancel the topological action of the agent + # and continue instead of "game over" self._time_apply_act += time.perf_counter() - beg_ # now it's time to run the powerflow properly # and to update the time dependant properties - self._update_alert_properties(action, lines_attacked, subs_attacked) - detailed_info, has_error = self._aux_run_pf_after_state_properly_set( - action, init_line_status, new_p, except_ - ) + if not is_done: + self._update_alert_properties(action, lines_attacked, subs_attacked) + detailed_info, has_error = self._aux_run_pf_after_state_properly_set( + action, init_line_status, new_p, except_ + ) else: has_error = True diff --git a/grid2op/Environment/environment.py b/grid2op/Environment/environment.py index c88b4f32b..0ea5592d8 100644 --- a/grid2op/Environment/environment.py +++ b/grid2op/Environment/environment.py @@ -10,7 +10,7 @@ import warnings import numpy as np import re -from typing import Union, Any, Dict +from typing import Union, Any, Dict, Literal import grid2op from grid2op.Opponent import OpponentSpace @@ -32,6 +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 class Environment(BaseEnv): @@ -82,6 +83,7 @@ def __init__( backend, parameters, name="unknown", + n_busbar=DEFAULT_N_BUSBAR_PER_SUB, names_chronics_to_backend=None, actionClass=TopologyAction, observationClass=CompleteObservation, @@ -148,6 +150,7 @@ def __init__( observation_bk_kwargs=observation_bk_kwargs, highres_sim_counter=highres_sim_counter, update_obs_after_reward=_update_obs_after_reward, + n_busbar=n_busbar, _init_obs=_init_obs, _is_test=_is_test, # is this created with "test=True" # TODO not implemented !! ) @@ -244,7 +247,8 @@ def _init_backend( self.backend._PATH_ENV = self.get_path_env() # all the above should be done in this exact order, otherwise some weird behaviour might occur # this is due to the class attribute - self.backend.set_env_name(self.name) + type(self.backend).set_env_name(self.name) + type(self.backend).set_n_busbar_per_sub(self._n_busbar) self.backend.load_grid( self._init_grid_path ) # the real powergrid of the environment @@ -520,7 +524,7 @@ def _handle_compat_glop_version(self, need_process_backend): # deals with the "sub_pos" vector for sub_id in range(cls_bk.n_sub): if (cls_bk.storage_to_subid == sub_id).any(): - stor_ids = np.where(cls_bk.storage_to_subid == sub_id)[0] + stor_ids = np.nonzero(cls_bk.storage_to_subid == sub_id)[0] stor_locs = cls_bk.storage_to_sub_pos[stor_ids] for stor_loc in sorted(stor_locs, reverse=True): for vect, sub_id_me in zip( @@ -897,7 +901,7 @@ def add_text_logger(self, logger=None): def reset(self, *, seed: Union[int, None] = None, - options: Union[Dict[str, Any], None] = None) -> BaseObservation: + options: Union[Dict[Union[str, Literal["time serie id"]], Union[int, str]], None] = None) -> BaseObservation: """ Reset the environment to a clean state. It will reload the next chronics if any. And reset the grid to a clean state. @@ -1136,6 +1140,7 @@ def get_kwargs(self, with_backend=True, with_chronics_handler=True): """ res = {} + res["n_busbar"] = self._n_busbar res["init_env_path"] = self._init_env_path res["init_grid_path"] = self._init_grid_path if with_chronics_handler: @@ -1774,6 +1779,7 @@ def get_params_for_runner(self): res["other_rewards"] = {k: v.rewardClass for k, v in self.other_rewards.items()} res["grid_layout"] = self.grid_layout res["name_env"] = self.name + res["n_busbar"] = self._n_busbar res["opponent_space_type"] = self._opponent_space_type res["opponent_action_class"] = self._opponent_action_class @@ -1798,6 +1804,7 @@ def get_params_for_runner(self): @classmethod def init_obj_from_kwargs(cls, + *, other_env_kwargs, init_env_path, init_grid_path, @@ -1830,39 +1837,41 @@ def init_obj_from_kwargs(cls, observation_bk_class, observation_bk_kwargs, _raw_backend_class, - _read_from_local_dir): - res = Environment(init_env_path=init_env_path, - init_grid_path=init_grid_path, - chronics_handler=chronics_handler, - backend=backend, - parameters=parameters, - name=name, - names_chronics_to_backend=names_chronics_to_backend, - actionClass=actionClass, - observationClass=observationClass, - rewardClass=rewardClass, - legalActClass=legalActClass, - voltagecontrolerClass=voltagecontrolerClass, - 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_budget_per_ts=opponent_budget_per_ts, - opponent_budget_class=opponent_budget_class, - opponent_attack_duration=opponent_attack_duration, - opponent_attack_cooldown=opponent_attack_cooldown, - kwargs_opponent=kwargs_opponent, - with_forecast=with_forecast, - attention_budget_cls=attention_budget_cls, - kwargs_attention_budget=kwargs_attention_budget, - has_attention_budget=has_attention_budget, - logger=logger, - kwargs_observation=kwargs_observation, - observation_bk_class=observation_bk_class, - observation_bk_kwargs=observation_bk_kwargs, - _raw_backend_class=_raw_backend_class, - _read_from_local_dir=_read_from_local_dir) + _read_from_local_dir, + n_busbar=DEFAULT_N_BUSBAR_PER_SUB): + res = cls(init_env_path=init_env_path, + init_grid_path=init_grid_path, + chronics_handler=chronics_handler, + backend=backend, + parameters=parameters, + name=name, + names_chronics_to_backend=names_chronics_to_backend, + actionClass=actionClass, + observationClass=observationClass, + rewardClass=rewardClass, + legalActClass=legalActClass, + voltagecontrolerClass=voltagecontrolerClass, + 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_budget_per_ts=opponent_budget_per_ts, + opponent_budget_class=opponent_budget_class, + opponent_attack_duration=opponent_attack_duration, + opponent_attack_cooldown=opponent_attack_cooldown, + kwargs_opponent=kwargs_opponent, + with_forecast=with_forecast, + attention_budget_cls=attention_budget_cls, + kwargs_attention_budget=kwargs_attention_budget, + has_attention_budget=has_attention_budget, + logger=logger, + kwargs_observation=kwargs_observation, + observation_bk_class=observation_bk_class, + observation_bk_kwargs=observation_bk_kwargs, + n_busbar=int(n_busbar), + _raw_backend_class=_raw_backend_class, + _read_from_local_dir=_read_from_local_dir) return res def generate_data(self, nb_year=1, nb_core=1, seed=None, **kwargs): @@ -1872,8 +1881,7 @@ def generate_data(self, nb_year=1, nb_core=1, seed=None, **kwargs): I also requires the lightsim2grid simulator. - This is only available for some environment (only the environment used for wcci 2022 competition at - time of writing). + This is only available for some environment (only the environment after 2022). Generating data takes some time (around 1 - 2 minutes to generate a weekly scenario) and this why we recommend to do it "offline" and then use the generated data for training or evaluation. diff --git a/grid2op/Environment/maskedEnvironment.py b/grid2op/Environment/maskedEnvironment.py index b97bf986c..bd7caaffa 100644 --- a/grid2op/Environment/maskedEnvironment.py +++ b/grid2op/Environment/maskedEnvironment.py @@ -10,10 +10,9 @@ import numpy as np from typing import Tuple, Union, List from grid2op.Environment.environment import Environment -from grid2op.Action import BaseAction -from grid2op.Observation import BaseObservation from grid2op.Exceptions import EnvError from grid2op.dtypes import dt_bool, dt_float, dt_int +from grid2op.Space import DEFAULT_N_BUSBAR_PER_SUB class MaskedEnvironment(Environment): # TODO heritage ou alors on met un truc de base @@ -122,7 +121,8 @@ def init_obj_from_kwargs(cls, observation_bk_class, observation_bk_kwargs, _raw_backend_class, - _read_from_local_dir): + _read_from_local_dir, + n_busbar=DEFAULT_N_BUSBAR_PER_SUB): res = MaskedEnvironment(grid2op_env={"init_env_path": init_env_path, "init_grid_path": init_grid_path, "chronics_handler": chronics_handler, @@ -153,6 +153,7 @@ def init_obj_from_kwargs(cls, "kwargs_observation": kwargs_observation, "observation_bk_class": observation_bk_class, "observation_bk_kwargs": observation_bk_kwargs, + "n_busbar": int(n_busbar), "_raw_backend_class": _raw_backend_class, "_read_from_local_dir": _read_from_local_dir}, **other_env_kwargs) diff --git a/grid2op/Environment/multiMixEnv.py b/grid2op/Environment/multiMixEnv.py index d20e73b75..e6ba1a646 100644 --- a/grid2op/Environment/multiMixEnv.py +++ b/grid2op/Environment/multiMixEnv.py @@ -10,10 +10,10 @@ import warnings import numpy as np import copy -from typing import Any, Dict, Tuple, Union, List +from typing import Any, Dict, Tuple, Union, List, Literal from grid2op.dtypes import dt_int, dt_float -from grid2op.Space import GridObjects, RandomObject +from grid2op.Space import GridObjects, RandomObject, DEFAULT_N_BUSBAR_PER_SUB from grid2op.Exceptions import EnvError, Grid2OpException from grid2op.Observation import BaseObservation @@ -161,6 +161,7 @@ def __init__( envs_dir, logger=None, experimental_read_from_local_dir=False, + n_busbar=DEFAULT_N_BUSBAR_PER_SUB, _add_to_name="", # internal, for test only, do not use ! _compat_glop_version=None, # internal, for test only, do not use ! _test=False, @@ -217,6 +218,7 @@ def __init__( backend=bk, _add_to_name=_add_to_name, _compat_glop_version=_compat_glop_version, + n_busbar=n_busbar, test=_test, logger=this_logger, experimental_read_from_local_dir=experimental_read_from_local_dir, @@ -225,6 +227,7 @@ def __init__( else: env = make( env_path, + n_busbar=n_busbar, _add_to_name=_add_to_name, _compat_glop_version=_compat_glop_version, test=_test, @@ -367,7 +370,7 @@ def reset(self, *, seed: Union[int, None] = None, random=False, - options: Union[Dict[str, Any], None] = None) -> BaseObservation: + options: Union[Dict[Union[str, Literal["time serie id"]], Union[int, str]], None] = None) -> BaseObservation: if self.__closed: raise EnvError("This environment is closed, you cannot use it.") diff --git a/grid2op/Environment/timedOutEnv.py b/grid2op/Environment/timedOutEnv.py index 84fafef58..2b7c16d85 100644 --- a/grid2op/Environment/timedOutEnv.py +++ b/grid2op/Environment/timedOutEnv.py @@ -8,11 +8,13 @@ import time from math import floor -from typing import Any, Dict, Tuple, Union, List +from typing import Any, Dict, Tuple, Union, List, Literal + from grid2op.Environment.environment import Environment from grid2op.Action import BaseAction from grid2op.Observation import BaseObservation from grid2op.Exceptions import EnvError +from grid2op.Space import DEFAULT_N_BUSBAR_PER_SUB class TimedOutEnvironment(Environment): # TODO heritage ou alors on met un truc de base @@ -212,7 +214,8 @@ def init_obj_from_kwargs(cls, observation_bk_class, observation_bk_kwargs, _raw_backend_class, - _read_from_local_dir): + _read_from_local_dir, + n_busbar=DEFAULT_N_BUSBAR_PER_SUB): res = TimedOutEnvironment(grid2op_env={"init_env_path": init_env_path, "init_grid_path": init_grid_path, "chronics_handler": chronics_handler, @@ -244,7 +247,8 @@ def init_obj_from_kwargs(cls, "observation_bk_class": observation_bk_class, "observation_bk_kwargs": observation_bk_kwargs, "_raw_backend_class": _raw_backend_class, - "_read_from_local_dir": _read_from_local_dir}, + "_read_from_local_dir": _read_from_local_dir, + "n_busbar": int(n_busbar)}, **other_env_kwargs) return res @@ -252,7 +256,7 @@ def init_obj_from_kwargs(cls, def reset(self, *, seed: Union[int, None] = None, - options: Union[Dict[str, Any], None] = None) -> BaseObservation: + options: Union[Dict[Union[str, Literal["time serie id"]], Union[int, str]], None] = None) -> BaseObservation: """Reset the environment. .. seealso:: diff --git a/grid2op/Episode/EpisodeReplay.py b/grid2op/Episode/EpisodeReplay.py index b21f21fc7..0e9d98a91 100644 --- a/grid2op/Episode/EpisodeReplay.py +++ b/grid2op/Episode/EpisodeReplay.py @@ -198,7 +198,7 @@ def replay_episode( from pygifsicle import optimize optimize(gif_path, options=["-w", "--no-conserve-memory"]) - except: + except Exception as exc_: warn_msg = ( "Failed to optimize .GIF size, but gif is still saved:\n" "Install dependencies to reduce size by ~3 folds\n" diff --git a/grid2op/Exceptions/__init__.py b/grid2op/Exceptions/__init__.py index f25ca1d26..f75a3bba6 100644 --- a/grid2op/Exceptions/__init__.py +++ b/grid2op/Exceptions/__init__.py @@ -52,6 +52,7 @@ "IsolatedElement", "DisconnectedLoad", "DisconnectedGenerator", + "ImpossibleTopology", "PlotError", "OpponentError", "UsedRunnerError", @@ -124,6 +125,8 @@ IsolatedElement, DisconnectedLoad, DisconnectedGenerator, + ImpossibleTopology, + ) DivergingPowerFlow = DivergingPowerflow # for compatibility with lightsim2grid diff --git a/grid2op/Exceptions/backendExceptions.py b/grid2op/Exceptions/backendExceptions.py index 297c63d69..e70cd645b 100644 --- a/grid2op/Exceptions/backendExceptions.py +++ b/grid2op/Exceptions/backendExceptions.py @@ -53,3 +53,10 @@ class DisconnectedLoad(BackendError): class DisconnectedGenerator(BackendError): """Specific error raised by the backend when a generator is disconnected""" pass + + +class ImpossibleTopology(BackendError): + """Specific error raised by the backend :func:`grid2op.Backend.Backend.apply_action` + when the player asked a topology (for example using `set_bus`) that + cannot be applied by the backend. + """ \ No newline at end of file diff --git a/grid2op/MakeEnv/Make.py b/grid2op/MakeEnv/Make.py index 8dbb24104..4692c6743 100644 --- a/grid2op/MakeEnv/Make.py +++ b/grid2op/MakeEnv/Make.py @@ -247,6 +247,7 @@ def _aux_make_multimix( dataset_path, test=False, experimental_read_from_local_dir=False, + n_busbar=2, _add_to_name="", _compat_glop_version=None, logger=None, @@ -258,6 +259,7 @@ def _aux_make_multimix( return MultiMixEnvironment( dataset_path, experimental_read_from_local_dir=experimental_read_from_local_dir, + n_busbar=n_busbar, _test=test, _add_to_name=_add_to_name, _compat_glop_version=_compat_glop_version, @@ -272,6 +274,7 @@ def make( test : bool=False, logger: Optional[logging.Logger]=None, experimental_read_from_local_dir : bool=False, + n_busbar=2, _add_to_name : str="", _compat_glop_version : Optional[str]=None, **kwargs @@ -286,6 +289,9 @@ def make( .. versionchanged:: 1.9.3 Remove the possibility to use this function with arguments (force kwargs) + + .. versionadded:: 1.10.0 + The `n_busbar` parameters Parameters ---------- @@ -308,6 +314,9 @@ def make( processing, you can set this flag to ``True``. See the doc of :func:`grid2op.Environment.BaseEnv.generate_classes` for more information. + n_busbar: ``int`` + Number of independant busbars allowed per substations. By default it's 2. + kwargs: Other keyword argument to give more control on the environment you are creating. See the Parameters information of the :func:`make_from_dataset_path`. @@ -350,7 +359,15 @@ def make( raise Grid2OpException("Impossible to create an environment without its name. Please call something like: \n" "> env = grid2op.make('l2rpn_case14_sandbox') \nor\n" "> env = grid2op.make('rte_case14_realistic')") + try: + n_busbar_int = int(n_busbar) + except Exception as exc_: + raise Grid2OpException("n_busbar parameters should be convertible to integer") from exc_ + if n_busbar != n_busbar_int: + 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: @@ -402,6 +419,7 @@ def make_from_path_fn_(*args, **kwargs): dataset_path=dataset, _add_to_name=_add_to_name_tmp, _compat_glop_version=_compat_glop_version_tmp, + n_busbar=n_busbar, **kwargs ) @@ -441,6 +459,7 @@ def make_from_path_fn_(*args, **kwargs): return make_from_path_fn( dataset_path=ds_path, logger=logger, + n_busbar=n_busbar, _add_to_name=_add_to_name, _compat_glop_version=_compat_glop_version, experimental_read_from_local_dir=experimental_read_from_local_dir, @@ -454,6 +473,7 @@ def make_from_path_fn_(*args, **kwargs): return make_from_path_fn( real_ds_path, logger=logger, + n_busbar=n_busbar, experimental_read_from_local_dir=experimental_read_from_local_dir, **kwargs ) @@ -472,6 +492,7 @@ def make_from_path_fn_(*args, **kwargs): return make_from_path_fn( dataset_path=real_ds_path, logger=logger, + n_busbar=n_busbar, experimental_read_from_local_dir=experimental_read_from_local_dir, **kwargs ) diff --git a/grid2op/MakeEnv/MakeFromPath.py b/grid2op/MakeEnv/MakeFromPath.py index 98054513f..88e3732e8 100644 --- a/grid2op/MakeEnv/MakeFromPath.py +++ b/grid2op/MakeEnv/MakeFromPath.py @@ -115,6 +115,7 @@ def make_from_dataset_path( dataset_path="/", logger=None, experimental_read_from_local_dir=False, + n_busbar=2, _add_to_name="", _compat_glop_version=None, **kwargs, @@ -150,6 +151,9 @@ def make_from_dataset_path( backend: ``grid2op.Backend.Backend``, optional The backend to use for the computation. If provided, it must be an instance of :class:`grid2op.Backend.Backend`. + n_busbar: ``int`` + Number of independant busbars allowed per substations. By default it's 2. + action_class: ``type``, optional Type of BaseAction the BaseAgent will be able to perform. If provided, it must be a subclass of :class:`grid2op.BaseAction.BaseAction` @@ -462,7 +466,7 @@ def make_from_dataset_path( try: int_ = int(el) available_parameters_int[int_] = el - except: + except Exception as exc_: pass max_ = np.max(list(available_parameters_int.keys())) keys_ = available_parameters_int[max_] @@ -885,6 +889,7 @@ def make_from_dataset_path( attention_budget_cls=attention_budget_class, kwargs_attention_budget=kwargs_attention_budget, logger=logger, + n_busbar=n_busbar, _compat_glop_version=_compat_glop_version, _read_from_local_dir=experimental_read_from_local_dir, kwargs_observation=kwargs_observation, diff --git a/grid2op/MakeEnv/UserUtils.py b/grid2op/MakeEnv/UserUtils.py index e7b0e7de9..3400f95c3 100644 --- a/grid2op/MakeEnv/UserUtils.py +++ b/grid2op/MakeEnv/UserUtils.py @@ -163,12 +163,12 @@ def change_local_dir(new_path): try: new_path = str(new_path) - except: + except Exception as exc_: raise Grid2OpException( 'The new path should be convertible to str. It is currently "{}"'.format( new_path ) - ) + ) from exc_ root_dir = os.path.split(new_path)[0] if not os.path.exists(root_dir): @@ -190,21 +190,21 @@ def change_local_dir(new_path): try: with open(DEFAULT_PATH_CONFIG, "r", encoding="utf-8") as f: newconfig = json.load(f) - except: + except Exception as exc_: raise Grid2OpException( 'Impossible to read the grid2op configuration files "{}". Make sure it is a ' 'valid json encoded with "utf-8" encoding.'.format(DEFAULT_PATH_CONFIG) - ) + ) from exc_ newconfig[KEY_DATA_PATH] = new_path try: with open(DEFAULT_PATH_CONFIG, "w", encoding="utf-8") as f: json.dump(fp=f, obj=newconfig, sort_keys=True, indent=4) - except: + except Exception as exc_: raise Grid2OpException( 'Impossible to write the grid2op configuration files "{}". Make sure you have ' "writing access to it.".format(DEFAULT_PATH_CONFIG) - ) + ) from exc_ grid2op.MakeEnv.PathUtils.DEFAULT_PATH_DATA = new_path diff --git a/grid2op/Observation/baseObservation.py b/grid2op/Observation/baseObservation.py index 6b401502b..be05db50a 100644 --- a/grid2op/Observation/baseObservation.py +++ b/grid2op/Observation/baseObservation.py @@ -14,7 +14,16 @@ import numpy as np from scipy.sparse import csr_matrix from typing import Optional +from packaging import version +from typing import Dict, Union, Tuple, List, Optional, Any, Literal +try: + from typing import Self +except ImportError: + from typing_extensions import Self + +import grid2op # for type hints +from grid2op.typing_variables import STEP_INFO_TYPING from grid2op.dtypes import dt_int, dt_float, dt_bool from grid2op.Exceptions import ( Grid2OpException, @@ -103,44 +112,44 @@ class BaseObservation(GridObjects): voltage angles (see :attr:`BaseObservation.support_theta`). p_or: :class:`numpy.ndarray`, dtype:float - The active power flow at the origin end of each powerline (expressed in MW). + The active power flow at the origin side of each powerline (expressed in MW). q_or: :class:`numpy.ndarray`, dtype:float - The reactive power flow at the origin end of each powerline (expressed in MVar). + The reactive power flow at the origin side of each powerline (expressed in MVar). v_or: :class:`numpy.ndarray`, dtype:float - The voltage magnitude at the bus to which the origin end of each powerline is connected (expressed in kV). + The voltage magnitude at the bus to which the origin side of each powerline is connected (expressed in kV). theta_or: :class:`numpy.ndarray`, dtype:float - The voltage angle at the bus to which the origin end of each powerline + The voltage angle at the bus to which the origin side of each powerline is connected (expressed in degree). Only availble if the backend supports the retrieval of voltage angles (see :attr:`BaseObservation.support_theta`). a_or: :class:`numpy.ndarray`, dtype:float - The current flow at the origin end of each powerline (expressed in A). + The current flow at the origin side of each powerline (expressed in A). p_ex: :class:`numpy.ndarray`, dtype:float - The active power flow at the extremity end of each powerline (expressed in MW). + The active power flow at the extremity side of each powerline (expressed in MW). q_ex: :class:`numpy.ndarray`, dtype:float - The reactive power flow at the extremity end of each powerline (expressed in MVar). + The reactive power flow at the extremity side of each powerline (expressed in MVar). v_ex: :class:`numpy.ndarray`, dtype:float - The voltage magnitude at the bus to which the extremity end of each powerline is connected (expressed in kV). + The voltage magnitude at the bus to which the extremity side of each powerline is connected (expressed in kV). theta_ex: :class:`numpy.ndarray`, dtype:float - The voltage angle at the bus to which the extremity end of each powerline + The voltage angle at the bus to which the extremity side of each powerline is connected (expressed in degree). Only availble if the backend supports the retrieval of voltage angles (see :attr:`BaseObservation.support_theta`). a_ex: :class:`numpy.ndarray`, dtype:float - The current flow at the extremity end of each powerline (expressed in A). + The current flow at the extremity side of each powerline (expressed in A). rho: :class:`numpy.ndarray`, dtype:float The capacity of each powerline. It is defined at the observed current flow divided by the thermal limit of each powerline (no unit) - topo_vect: :class:`numpy.ndarray`, dtype:int + topo_vect: :class:`numpy.ndarray`, dtype:int For each object (load, generator, ends of a powerline) it gives on which bus this object is connected in its substation. See :func:`grid2op.Backend.Backend.get_topo_vect` for more information. @@ -151,16 +160,16 @@ class BaseObservation(GridObjects): timestep_overflow: :class:`numpy.ndarray`, dtype:int Gives the number of time steps since a powerline is in overflow. - time_before_cooldown_line: :class:`numpy.ndarray`, dtype:int + time_before_cooldown_line: :class:`numpy.ndarray`, dtype:int For each powerline, it gives the number of time step the powerline is unavailable due to "cooldown" - (see :attr:`grid2op.Parameters.NB_TIMESTEP_COOLDOWN_LINE` for more information). 0 means the + (see :attr:`grid2op.Parameters.Parameters.NB_TIMESTEP_COOLDOWN_LINE` for more information). 0 means the an action will be able to act on this same powerline, a number > 0 (eg 1) means that an action at this time step cannot act on this powerline (in the example the agent have to wait 1 time step) time_before_cooldown_sub: :class:`numpy.ndarray`, dtype:int Same as :attr:`BaseObservation.time_before_cooldown_line` but for substations. For each substation, it gives the number of timesteps to wait before acting on this substation (see - see :attr:`grid2op.Parameters.NB_TIMESTEP_COOLDOWN_SUB` for more information). + see :attr:`grid2op.Parameters.Parameters.NB_TIMESTEP_COOLDOWN_SUB` for more information). time_next_maintenance: :class:`numpy.ndarray`, dtype:int For each powerline, it gives the time of the next planned maintenance. For example if there is: @@ -401,13 +410,13 @@ class BaseObservation(GridObjects): For each attackable line `i` it says: - obs.attack_under_alert[i] = 0 => attackable line i has not been attacked OR it - has been attacked before the relevant window (env.parameters.ALERT_TIME_WINDOW) + has been attacked before the relevant window (`env.parameters.ALERT_TIME_WINDOW`) - obs.attack_under_alert[i] = -1 => attackable line i has been attacked and (before the attack) no alert was sent (so your agent expects to survive at least - env.parameters.ALERT_TIME_WINDOW steps) + `env.parameters.ALERT_TIME_WINDOW` steps) - obs.attack_under_alert[i] = +1 => attackable line i has been attacked and (before the attack) an alert was sent (so your agent expects to "game over" within the next - env.parameters.ALERT_TIME_WINDOW steps) + `env.parameters.ALERT_TIME_WINDOW` steps) _shunt_p: :class:`numpy.ndarray`, dtype:float Shunt active value (only available if shunts are available) (in MW) @@ -510,64 +519,65 @@ def __init__(self, self.minute_of_hour = dt_int(0) self.day_of_week = dt_int(0) - self.timestep_overflow = np.empty(shape=(self.n_line,), dtype=dt_int) + cls = type(self) + self.timestep_overflow = np.empty(shape=(cls.n_line,), dtype=dt_int) # 0. (line is disconnected) / 1. (line is connected) - self.line_status = np.empty(shape=self.n_line, dtype=dt_bool) + self.line_status = np.empty(shape=cls.n_line, dtype=dt_bool) # topological vector - self.topo_vect = np.empty(shape=self.dim_topo, dtype=dt_int) + self.topo_vect = np.empty(shape=cls.dim_topo, dtype=dt_int) # generators information - self.gen_p = np.empty(shape=self.n_gen, dtype=dt_float) - self.gen_q = np.empty(shape=self.n_gen, dtype=dt_float) - self.gen_v = np.empty(shape=self.n_gen, dtype=dt_float) - self.gen_margin_up = np.empty(shape=self.n_gen, dtype=dt_float) - self.gen_margin_down = np.empty(shape=self.n_gen, dtype=dt_float) + self.gen_p = np.empty(shape=cls.n_gen, dtype=dt_float) + self.gen_q = np.empty(shape=cls.n_gen, dtype=dt_float) + self.gen_v = np.empty(shape=cls.n_gen, dtype=dt_float) + self.gen_margin_up = np.empty(shape=cls.n_gen, dtype=dt_float) + self.gen_margin_down = np.empty(shape=cls.n_gen, dtype=dt_float) # loads information - self.load_p = np.empty(shape=self.n_load, dtype=dt_float) - self.load_q = np.empty(shape=self.n_load, dtype=dt_float) - self.load_v = np.empty(shape=self.n_load, dtype=dt_float) + self.load_p = np.empty(shape=cls.n_load, dtype=dt_float) + self.load_q = np.empty(shape=cls.n_load, dtype=dt_float) + self.load_v = np.empty(shape=cls.n_load, dtype=dt_float) # lines origin information - self.p_or = np.empty(shape=self.n_line, dtype=dt_float) - self.q_or = np.empty(shape=self.n_line, dtype=dt_float) - self.v_or = np.empty(shape=self.n_line, dtype=dt_float) - self.a_or = np.empty(shape=self.n_line, dtype=dt_float) + self.p_or = np.empty(shape=cls.n_line, dtype=dt_float) + self.q_or = np.empty(shape=cls.n_line, dtype=dt_float) + self.v_or = np.empty(shape=cls.n_line, dtype=dt_float) + self.a_or = np.empty(shape=cls.n_line, dtype=dt_float) # lines extremity information - self.p_ex = np.empty(shape=self.n_line, dtype=dt_float) - self.q_ex = np.empty(shape=self.n_line, dtype=dt_float) - self.v_ex = np.empty(shape=self.n_line, dtype=dt_float) - self.a_ex = np.empty(shape=self.n_line, dtype=dt_float) + self.p_ex = np.empty(shape=cls.n_line, dtype=dt_float) + self.q_ex = np.empty(shape=cls.n_line, dtype=dt_float) + self.v_ex = np.empty(shape=cls.n_line, dtype=dt_float) + self.a_ex = np.empty(shape=cls.n_line, dtype=dt_float) # lines relative flows - self.rho = np.empty(shape=self.n_line, dtype=dt_float) + self.rho = np.empty(shape=cls.n_line, dtype=dt_float) # cool down and reconnection time after hard overflow, soft overflow or cascading failure - self.time_before_cooldown_line = np.empty(shape=self.n_line, dtype=dt_int) - self.time_before_cooldown_sub = np.empty(shape=self.n_sub, dtype=dt_int) + self.time_before_cooldown_line = np.empty(shape=cls.n_line, dtype=dt_int) + self.time_before_cooldown_sub = np.empty(shape=cls.n_sub, dtype=dt_int) self.time_next_maintenance = 1 * self.time_before_cooldown_line self.duration_next_maintenance = 1 * self.time_before_cooldown_line # redispatching - self.target_dispatch = np.empty(shape=self.n_gen, dtype=dt_float) - self.actual_dispatch = np.empty(shape=self.n_gen, dtype=dt_float) + self.target_dispatch = np.empty(shape=cls.n_gen, dtype=dt_float) + self.actual_dispatch = np.empty(shape=cls.n_gen, dtype=dt_float) # storage unit - self.storage_charge = np.empty(shape=self.n_storage, dtype=dt_float) # in MWh + self.storage_charge = np.empty(shape=cls.n_storage, dtype=dt_float) # in MWh self.storage_power_target = np.empty( - shape=self.n_storage, dtype=dt_float + shape=cls.n_storage, dtype=dt_float ) # in MW - self.storage_power = np.empty(shape=self.n_storage, dtype=dt_float) # in MW + self.storage_power = np.empty(shape=cls.n_storage, dtype=dt_float) # in MW # attention budget self.is_alarm_illegal = np.ones(shape=1, dtype=dt_bool) self.time_since_last_alarm = np.empty(shape=1, dtype=dt_int) - self.last_alarm = np.empty(shape=self.dim_alarms, dtype=dt_int) + self.last_alarm = np.empty(shape=cls.dim_alarms, dtype=dt_int) self.attention_budget = np.empty(shape=1, dtype=dt_float) self.was_alarm_used_after_game_over = np.zeros(shape=1, dtype=dt_bool) # alert - dim_alert = type(self).dim_alerts + dim_alert = cls.dim_alerts self.active_alert = np.empty(shape=dim_alert, dtype=dt_bool) self.attack_under_alert = np.empty(shape=dim_alert, dtype=dt_int) self.time_since_last_alert = np.empty(shape=dim_alert, dtype=dt_int) @@ -583,33 +593,33 @@ def __init__(self, self._vectorized = None # for shunt (these are not stored!) - if type(self).shunts_data_available: - self._shunt_p = np.empty(shape=self.n_shunt, dtype=dt_float) - self._shunt_q = np.empty(shape=self.n_shunt, dtype=dt_float) - self._shunt_v = np.empty(shape=self.n_shunt, dtype=dt_float) - self._shunt_bus = np.empty(shape=self.n_shunt, dtype=dt_int) + if cls.shunts_data_available: + self._shunt_p = np.empty(shape=cls.n_shunt, dtype=dt_float) + self._shunt_q = np.empty(shape=cls.n_shunt, dtype=dt_float) + self._shunt_v = np.empty(shape=cls.n_shunt, dtype=dt_float) + self._shunt_bus = np.empty(shape=cls.n_shunt, dtype=dt_int) - self._thermal_limit = np.empty(shape=self.n_line, dtype=dt_float) + self._thermal_limit = np.empty(shape=cls.n_line, dtype=dt_float) - self.gen_p_before_curtail = np.empty(shape=self.n_gen, dtype=dt_float) - self.curtailment = np.empty(shape=self.n_gen, dtype=dt_float) - self.curtailment_limit = np.empty(shape=self.n_gen, dtype=dt_float) - self.curtailment_limit_effective = np.empty(shape=self.n_gen, dtype=dt_float) + self.gen_p_before_curtail = np.empty(shape=cls.n_gen, dtype=dt_float) + self.curtailment = np.empty(shape=cls.n_gen, dtype=dt_float) + self.curtailment_limit = np.empty(shape=cls.n_gen, dtype=dt_float) + self.curtailment_limit_effective = np.empty(shape=cls.n_gen, dtype=dt_float) # the "theta" (voltage angle, in degree) self.support_theta = False - self.theta_or = np.empty(shape=self.n_line, dtype=dt_float) - self.theta_ex = np.empty(shape=self.n_line, dtype=dt_float) - self.load_theta = np.empty(shape=self.n_load, dtype=dt_float) - self.gen_theta = np.empty(shape=self.n_gen, dtype=dt_float) - self.storage_theta = np.empty(shape=self.n_storage, dtype=dt_float) + self.theta_or = np.empty(shape=cls.n_line, dtype=dt_float) + self.theta_ex = np.empty(shape=cls.n_line, dtype=dt_float) + self.load_theta = np.empty(shape=cls.n_load, dtype=dt_float) + self.gen_theta = np.empty(shape=cls.n_gen, dtype=dt_float) + self.storage_theta = np.empty(shape=cls.n_storage, dtype=dt_float) # counter self.current_step = dt_int(0) self.max_step = dt_int(np.iinfo(dt_int).max) self.delta_time = dt_float(5.0) - def _aux_copy(self, other): + def _aux_copy(self, other : Self) -> None: attr_simple = [ "max_step", "current_step", @@ -684,12 +694,12 @@ def _aux_copy(self, other): attr_vect += ["_shunt_bus", "_shunt_v", "_shunt_q", "_shunt_p"] for attr_nm in attr_simple: - setattr(other, attr_nm, getattr(self, attr_nm)) + setattr(other, attr_nm, copy.deepcopy(getattr(self, attr_nm))) for attr_nm in attr_vect: getattr(other, attr_nm)[:] = getattr(self, attr_nm) - def __copy__(self): + def __copy__(self) -> Self: res = type(self)(obs_env=self._obs_env, action_helper=self.action_helper, kwargs_env=self._ptr_kwargs_env) @@ -710,7 +720,7 @@ def __copy__(self): return res - def __deepcopy__(self, memodict={}): + def __deepcopy__(self, memodict={}) -> Self: res = type(self)(obs_env=self._obs_env, action_helper=self.action_helper, kwargs_env=self._ptr_kwargs_env) @@ -741,7 +751,12 @@ def state_of( line_id=None, storage_id=None, substation_id=None, - ): + ) -> Dict[Literal["p", "q", "v", "theta", "bus", "sub_id", "actual_dispatch", "target_dispatch", + "maintenance", "cooldown_time", "storage_power", "storage_charge", + "storage_power_target", "storage_theta", + "topo_vect", "nb_bus", "origin", "extremity"], + Union[int, float, Dict[Literal["p", "q", "v", "a", "sub_id", "bus", "theta"], Union[int, float]]] + ]: """ Return the state of this action on a give unique load, generator unit, powerline of substation. Only one of load, gen, line or substation should be filled. @@ -848,7 +863,6 @@ def state_of( raise Grid2OpException( "action.effect_on should only be called with named argument." ) - if ( load_id is None and gen_id is None @@ -861,6 +875,7 @@ def state_of( 'Please provide "load_id", "gen_id", "line_id", "storage_id" or ' '"substation_id"' ) + cls = type(self) if load_id is not None: if ( @@ -882,7 +897,7 @@ def state_of( "q": self.load_q[load_id], "v": self.load_v[load_id], "bus": self.topo_vect[self.load_pos_topo_vect[load_id]], - "sub_id": self.load_to_subid[load_id], + "sub_id": cls.load_to_subid[load_id], } if self.support_theta: res["theta"] = self.load_theta[load_id] @@ -907,7 +922,7 @@ def state_of( "q": self.gen_q[gen_id], "v": self.gen_v[gen_id], "bus": self.topo_vect[self.gen_pos_topo_vect[gen_id]], - "sub_id": self.gen_to_subid[gen_id], + "sub_id": cls.gen_to_subid[gen_id], "target_dispatch": self.target_dispatch[gen_id], "actual_dispatch": self.target_dispatch[gen_id], "curtailment": self.curtailment[gen_id], @@ -938,8 +953,8 @@ def state_of( "q": self.q_or[line_id], "v": self.v_or[line_id], "a": self.a_or[line_id], - "bus": self.topo_vect[self.line_or_pos_topo_vect[line_id]], - "sub_id": self.line_or_to_subid[line_id], + "bus": self.topo_vect[cls.line_or_pos_topo_vect[line_id]], + "sub_id": cls.line_or_to_subid[line_id], } if self.support_theta: res["origin"]["theta"] = self.theta_or[line_id] @@ -949,8 +964,8 @@ def state_of( "q": self.q_ex[line_id], "v": self.v_ex[line_id], "a": self.a_ex[line_id], - "bus": self.topo_vect[self.line_ex_pos_topo_vect[line_id]], - "sub_id": self.line_ex_to_subid[line_id], + "bus": self.topo_vect[cls.line_ex_pos_topo_vect[line_id]], + "sub_id": cls.line_ex_to_subid[line_id], } if self.support_theta: res["origin"]["theta"] = self.theta_ex[line_id] @@ -967,7 +982,7 @@ def state_of( elif storage_id is not None: if substation_id is not None: raise Grid2OpException(ERROR_ONLY_SINGLE_EL) - if storage_id >= self.n_storage: + if storage_id >= cls.n_storage: raise Grid2OpException( 'There are no storage unit with id "storage_id={}" in this grid.'.format( storage_id @@ -977,23 +992,24 @@ def state_of( raise Grid2OpException("`storage_id` should be a positive integer") res = {} + res["p"] = self.storage_power[storage_id] res["storage_power"] = self.storage_power[storage_id] res["storage_charge"] = self.storage_charge[storage_id] res["storage_power_target"] = self.storage_power_target[storage_id] - res["bus"] = self.topo_vect[self.storage_pos_topo_vect[storage_id]] - res["sub_id"] = self.storage_to_subid[storage_id] + res["bus"] = self.topo_vect[cls.storage_pos_topo_vect[storage_id]] + res["sub_id"] = cls.storage_to_subid[storage_id] if self.support_theta: res["theta"] = self.storage_theta[storage_id] else: - if substation_id >= len(self.sub_info): + if substation_id >= len(cls.sub_info): raise Grid2OpException( 'There are no substation of id "substation_id={}" in this grid.'.format( substation_id ) ) - beg_ = int(self.sub_info[:substation_id].sum()) - end_ = int(beg_ + self.sub_info[substation_id]) + beg_ = int(cls.sub_info[:substation_id].sum()) + end_ = int(beg_ + cls.sub_info[substation_id]) topo_sub = self.topo_vect[beg_:end_] if (topo_sub > 0).any(): nb_bus = ( @@ -1010,7 +1026,7 @@ def state_of( return res @classmethod - def process_shunt_satic_data(cls): + def process_shunt_satic_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) @@ -1026,131 +1042,147 @@ def process_shunt_satic_data(cls): return super().process_shunt_satic_data() @classmethod - def process_grid2op_compat(cls): - if cls.glop_version == cls.BEFORE_COMPAT_VERSION: - # oldest version: no storage and no curtailment available - - # this is really important, otherwise things from grid2op base types will be affected - cls.attr_list_vect = copy.deepcopy(cls.attr_list_vect) - cls.attr_list_set = copy.deepcopy(cls.attr_list_set) - - # deactivate storage - cls.set_no_storage() - for el in ["storage_charge", "storage_power_target", "storage_power"]: - if el in cls.attr_list_vect: - try: - cls.attr_list_vect.remove(el) - except ValueError: - pass - - # remove the curtailment - for el in ["gen_p_before_curtail", "curtailment", "curtailment_limit"]: - if el in cls.attr_list_vect: - try: - cls.attr_list_vect.remove(el) - except ValueError: - pass - - cls.attr_list_set = set(cls.attr_list_vect) - - if cls.glop_version < "1.6.0" or cls.glop_version == cls.BEFORE_COMPAT_VERSION: - # this feature did not exist before and was introduced in grid2op 1.6.0 - cls.attr_list_vect = copy.deepcopy(cls.attr_list_vect) - cls.attr_list_set = copy.deepcopy(cls.attr_list_set) - cls.dim_alarms = 0 - for el in [ - "is_alarm_illegal", - "time_since_last_alarm", - "last_alarm", - "attention_budget", - "was_alarm_used_after_game_over", - ]: + def _aux_process_grid2op_compat_old(cls): + # this is really important, otherwise things from grid2op base types will be affected + cls.attr_list_vect = copy.deepcopy(cls.attr_list_vect) + cls.attr_list_set = copy.deepcopy(cls.attr_list_set) + + # deactivate storage + cls.set_no_storage() + for el in ["storage_charge", "storage_power_target", "storage_power"]: + if el in cls.attr_list_vect: try: cls.attr_list_vect.remove(el) - except ValueError as exc_: - # this attribute was not there in the first place + except ValueError: pass - for el in ["_shunt_p", "_shunt_q", "_shunt_v", "_shunt_bus"]: - # added in grid2op 1.6.0 mainly for the EpisodeReboot + # remove the curtailment + for el in ["gen_p_before_curtail", "curtailment", "curtailment_limit"]: + if el in cls.attr_list_vect: try: cls.attr_list_vect.remove(el) - except ValueError as exc_: - # this attribute was not there in the first place + except ValueError: pass - cls.attr_list_set = set(cls.attr_list_vect) + @classmethod + def _aux_process_grid2op_compat_160(cls): + cls.attr_list_vect = copy.deepcopy(cls.attr_list_vect) + cls.dim_alarms = 0 + for el in [ + "is_alarm_illegal", + "time_since_last_alarm", + "last_alarm", + "attention_budget", + "was_alarm_used_after_game_over", + ]: + try: + cls.attr_list_vect.remove(el) + except ValueError as exc_: + # this attribute was not there in the first place + pass + + for el in ["_shunt_p", "_shunt_q", "_shunt_v", "_shunt_bus"]: + # added in grid2op 1.6.0 mainly for the EpisodeReboot + try: + cls.attr_list_vect.remove(el) + except ValueError as exc_: + # this attribute was not there in the first place + pass + + @classmethod + def _aux_process_grid2op_compat_164(cls): + cls.attr_list_vect = copy.deepcopy(cls.attr_list_vect) + + for el in ["max_step", "current_step"]: + try: + cls.attr_list_vect.remove(el) + except ValueError as exc_: + # this attribute was not there in the first place + pass + + @classmethod + def _aux_process_grid2op_compat_165(cls): + cls.attr_list_vect = copy.deepcopy(cls.attr_list_vect) + + for el in ["delta_time"]: + try: + cls.attr_list_vect.remove(el) + except ValueError as exc_: + # this attribute was not there in the first place + pass + + @classmethod + def _aux_process_grid2op_compat_166(cls): + cls.attr_list_vect = copy.deepcopy(cls.attr_list_vect) - if cls.glop_version < "1.6.4" or cls.glop_version == cls.BEFORE_COMPAT_VERSION: - # "current_step", "max_step" were added in grid2Op 1.6.4 - cls.attr_list_vect = copy.deepcopy(cls.attr_list_vect) - cls.attr_list_set = copy.deepcopy(cls.attr_list_set) + for el in [ + "gen_margin_up", + "gen_margin_down", + "curtailment_limit_effective", + ]: + try: + cls.attr_list_vect.remove(el) + except ValueError as exc_: + # this attribute was not there in the first place + pass + + @classmethod + def _aux_process_grid2op_compat_191(cls): + cls.attr_list_vect = copy.deepcopy(cls.attr_list_vect) - for el in ["max_step", "current_step"]: - try: - cls.attr_list_vect.remove(el) - except ValueError as exc_: - # this attribute was not there in the first place - pass - cls.attr_list_set = set(cls.attr_list_vect) + for el in [ + "active_alert", + "attack_under_alert", + "time_since_last_alert", + "alert_duration", + "total_number_of_alert", + "time_since_last_attack", + "was_alert_used_after_attack" + ]: + try: + cls.attr_list_vect.remove(el) + except ValueError as exc_: + # this attribute was not there in the first place + pass + + @classmethod + def process_grid2op_compat(cls) -> None: + super().process_grid2op_compat() + glop_ver = cls._get_grid2op_version_as_version_obj() + + if cls.glop_version == cls.BEFORE_COMPAT_VERSION: + # oldest version: no storage and no curtailment available + cls._aux_process_grid2op_compat_old() + + if glop_ver < version.parse("1.6.0"): + # this feature did not exist before and was introduced in grid2op 1.6.0 + cls._aux_process_grid2op_compat_160() - if cls.glop_version < "1.6.5" or cls.glop_version == cls.BEFORE_COMPAT_VERSION: + if glop_ver < version.parse("1.6.4"): + # "current_step", "max_step" were added in grid2Op 1.6.4 + cls._aux_process_grid2op_compat_164() + + if glop_ver < version.parse("1.6.5"): # "current_step", "max_step" were added in grid2Op 1.6.5 - cls.attr_list_vect = copy.deepcopy(cls.attr_list_vect) - cls.attr_list_set = copy.deepcopy(cls.attr_list_set) - - for el in ["delta_time"]: - try: - cls.attr_list_vect.remove(el) - except ValueError as exc_: - # this attribute was not there in the first place - pass - cls.attr_list_set = set(cls.attr_list_vect) - - if cls.glop_version < "1.6.6" or cls.glop_version == cls.BEFORE_COMPAT_VERSION: + cls._aux_process_grid2op_compat_165() + + if glop_ver < version.parse("1.6.6"): # "gen_margin_up", "gen_margin_down" were added in grid2Op 1.6.6 - cls.attr_list_vect = copy.deepcopy(cls.attr_list_vect) - cls.attr_list_set = copy.deepcopy(cls.attr_list_set) - - for el in [ - "gen_margin_up", - "gen_margin_down", - "curtailment_limit_effective", - ]: - try: - cls.attr_list_vect.remove(el) - except ValueError as exc_: - # this attribute was not there in the first place - pass - cls.attr_list_set = set(cls.attr_list_vect) - - if cls.glop_version < "1.9.1" or cls.glop_version == cls.BEFORE_COMPAT_VERSION: + cls._aux_process_grid2op_compat_166() + + if glop_ver < version.parse("1.9.1"): # alert attributes have been added in 1.9.1 - cls.attr_list_vect = copy.deepcopy(cls.attr_list_vect) - cls.attr_list_set = copy.deepcopy(cls.attr_list_set) - - for el in [ - "active_alert", - "attack_under_alert", - "time_since_last_alert", - "alert_duration", - "total_number_of_alert", - "time_since_last_attack", - "was_alert_used_after_attack" - ]: - try: - cls.attr_list_vect.remove(el) - except ValueError as exc_: - # this attribute was not there in the first place - pass - cls.attr_list_set = set(cls.attr_list_vect) + cls._aux_process_grid2op_compat_191() + + cls.attr_list_set = copy.deepcopy(cls.attr_list_set) + cls.attr_list_set = set(cls.attr_list_vect) - def shape(self): + def shape(self) -> np.ndarray: return type(self).shapes() - def dtype(self): + def dtype(self) -> np.ndarray: return type(self).dtypes() - def reset(self): + def reset(self) -> None: """ INTERNAL @@ -1259,8 +1291,15 @@ def reset(self): self.max_step = dt_int(np.iinfo(dt_int).max) self.delta_time = dt_float(5.0) - def set_game_over(self, env=None): + def set_game_over(self, + env: Optional["grid2op.Environment.Environment"]=None) -> None: """ + INTERNAL + + .. warning:: /!\\\\ Internal, do not use unless you know what you are doing /!\\\\ + This is used internally to reset an observation in a fixed state, possibly after + a game over. + Set the observation to the "game over" state: - all powerlines are disconnected @@ -1389,7 +1428,7 @@ def set_game_over(self, env=None): # was_alert_used_after_attack not updated here in this case # attack_under_alert not updated here in this case - def __compare_stats(self, other, name): + def __compare_stats(self, other: Self, name: str) -> bool: attr_me = getattr(self, name) attr_other = getattr(other, name) if attr_me is None and attr_other is not None: @@ -1419,7 +1458,7 @@ def __compare_stats(self, other, name): return False return True - def __eq__(self, other): + def __eq__(self, other : Self) -> bool: """ INTERNAL @@ -1440,7 +1479,7 @@ def __eq__(self, other): declared as different. **Known issue** if two backend are different, but the description of the _grid are identical (ie all - n_gen, n_load, n_line, sub_info, dim_topo, all vectors \*_to_subid, and \*_pos_topo_vect are + n_gen, n_load, n_line, sub_info, dim_topo, all vectors \\*_to_subid, and \\*_pos_topo_vect are identical) then this method will not detect the backend are different, and the action could be declared as identical. For now, this is only a theoretical behaviour: if everything is the same, then probably, up to the naming convention, then the powergrid are identical too. @@ -1480,13 +1519,50 @@ def __eq__(self, other): return True - def __sub__(self, other): + def _aux_sub_get_attr_diff(self, me_, oth_): + diff_ = None + if me_ is None and oth_ is None: + diff_ = None + elif me_ is not None and oth_ is None: + diff_ = me_ + elif me_ is None and oth_ is not None: + if oth_.dtype == dt_bool: + diff_ = np.full(oth_.shape, fill_value=False, dtype=dt_bool) + else: + diff_ = -oth_ + else: + # both are not None + if oth_.dtype == dt_bool: + diff_ = ~np.logical_xor(me_, oth_) + else: + diff_ = me_ - oth_ + return diff_ + + def __sub__(self, other : Self) -> Self: """ - computes the difference between two observation, and return an observation corresponding to + Computes the difference between two observations, and return an observation corresponding to this difference. This can be used to easily plot the difference between two observations at different step for example. + + + Examples + ---------- + + .. code-block:: python + + import grid2op + env = grid2op.make("l2rpn_case14_sandbox") + + obs_0 = env.reset() + + action = env.action_space() + obs_1, reward, done, info = env.step(action) + + diff_obs = obs_1 - obs_0 + + diff_obs.gen_p # the variation in generator between these steps """ same_grid = type(self).same_grid_class(type(other)) if not same_grid: @@ -1503,25 +1579,11 @@ def __sub__(self, other): for stat_nm in self._attr_eq: me_ = getattr(self, stat_nm) oth_ = getattr(other, stat_nm) - if me_ is None and oth_ is None: - diff_ = None - elif me_ is not None and oth_ is None: - diff_ = me_ - elif me_ is None and oth_ is not None: - if oth_.dtype == dt_bool: - diff_ = np.full(oth_.shape, fill_value=False, dtype=dt_bool) - else: - diff_ = -oth_ - else: - # both are not None - if oth_.dtype == dt_bool: - diff_ = ~np.logical_xor(me_, oth_) - else: - diff_ = me_ - oth_ + diff_ = self._aux_sub_get_attr_diff(me_, oth_) res.__setattr__(stat_nm, diff_) return res - def where_different(self, other): + def where_different(self, other : Self) -> Tuple[Self, List]: """ Returns the difference between two observation. @@ -1532,7 +1594,7 @@ def where_different(self, other): Returns ------- - diff_: :class:`grid2op.Observation.BaseObservation` + diff_: :class:`BaseObservation` The observation showing the difference between `self` and `other` attr_nm: ``list`` List of string representing the names of the different attributes. It's [] if the two observations @@ -1552,7 +1614,7 @@ def where_different(self, other): return diff_, res @abstractmethod - def update(self, env, with_forecast=True): + def update(self, env: "grid2op.Environment.Environment", with_forecast: bool=True) -> None: """ INTERNAL @@ -1589,7 +1651,69 @@ def update(self, env, with_forecast=True): """ pass - def connectivity_matrix(self, as_csr_matrix=False): + def _aux_build_conn_mat(self, as_csr_matrix): + # self._connectivity_matrix_ = np.zeros(shape=(self.dim_topo, self.dim_topo), dtype=dt_float) + # fill it by block for the objects + beg_ = 0 + end_ = 0 + row_ind = [] + col_ind = [] + cls = type(self) + for sub_id, nb_obj in enumerate(cls.sub_info): + # it must be a vanilla python integer, otherwise it's not handled by some backend + # especially if written in c++ + nb_obj = int(nb_obj) + end_ += nb_obj + # tmp = np.zeros(shape=(nb_obj, nb_obj), dtype=dt_float) + for obj1 in range(nb_obj): + my_bus = self.topo_vect[beg_ + obj1] + if my_bus == -1: + # object is disconnected, nothing is done + continue + # connect an object to itself + row_ind.append(beg_ + obj1) + col_ind.append(beg_ + obj1) + + # connect the other objects to it + for obj2 in range(obj1 + 1, nb_obj): + my_bus2 = self.topo_vect[beg_ + obj2] + if my_bus2 == -1: + # object is disconnected, nothing is done + continue + if my_bus == my_bus2: + # objects are on the same bus + # tmp[obj1, obj2] = 1 + # tmp[obj2, obj1] = 1 + row_ind.append(beg_ + obj2) + col_ind.append(beg_ + obj1) + row_ind.append(beg_ + obj1) + col_ind.append(beg_ + obj2) + beg_ += nb_obj + + # both ends of a line are connected together (if line is connected) + for q_id in range(cls.n_line): + if self.line_status[q_id]: + # if powerline is connected connect both its side + row_ind.append(cls.line_or_pos_topo_vect[q_id]) + col_ind.append(cls.line_ex_pos_topo_vect[q_id]) + row_ind.append(cls.line_ex_pos_topo_vect[q_id]) + col_ind.append(cls.line_or_pos_topo_vect[q_id]) + row_ind = np.array(row_ind).astype(dt_int) + col_ind = np.array(col_ind).astype(dt_int) + if not as_csr_matrix: + self._connectivity_matrix_ = np.zeros( + shape=(cls.dim_topo, cls.dim_topo), dtype=dt_float + ) + self._connectivity_matrix_[row_ind.T, col_ind] = 1.0 + else: + data = np.ones(row_ind.shape[0], dtype=dt_float) + self._connectivity_matrix_ = csr_matrix( + (data, (row_ind, col_ind)), + shape=(cls.dim_topo, cls.dim_topo), + dtype=dt_float, + ) + + def connectivity_matrix(self, as_csr_matrix: bool=False) -> Union[np.ndarray, csr_matrix]: """ Computes and return the "connectivity matrix" `con_mat`. Let "dim_topo := 2 * n_line + n_prod + n_conso + n_storage" (the total number of elements on the grid) @@ -1672,92 +1796,37 @@ def connectivity_matrix(self, as_csr_matrix=False): # - assign bus 2 to load 0 [on substation 1] # -> one of them is on bus 1 [line (extremity) 0] and the other on bus 2 [load 0] """ - if ( - self._connectivity_matrix_ is None - or ( - isinstance(self._connectivity_matrix_, csr_matrix) and not as_csr_matrix - ) - or ( - (not isinstance(self._connectivity_matrix_, csr_matrix)) - and as_csr_matrix - ) - ): - # self._connectivity_matrix_ = np.zeros(shape=(self.dim_topo, self.dim_topo), dtype=dt_float) - # fill it by block for the objects - beg_ = 0 - end_ = 0 - row_ind = [] - col_ind = [] - for sub_id, nb_obj in enumerate(self.sub_info): - # it must be a vanilla python integer, otherwise it's not handled by some backend - # especially if written in c++ - nb_obj = int(nb_obj) - end_ += nb_obj - # tmp = np.zeros(shape=(nb_obj, nb_obj), dtype=dt_float) - for obj1 in range(nb_obj): - my_bus = self.topo_vect[beg_ + obj1] - if my_bus == -1: - # object is disconnected, nothing is done - continue - # connect an object to itself - row_ind.append(beg_ + obj1) - col_ind.append(beg_ + obj1) - - # connect the other objects to it - for obj2 in range(obj1 + 1, nb_obj): - my_bus2 = self.topo_vect[beg_ + obj2] - if my_bus2 == -1: - # object is disconnected, nothing is done - continue - if my_bus == my_bus2: - # objects are on the same bus - # tmp[obj1, obj2] = 1 - # tmp[obj2, obj1] = 1 - row_ind.append(beg_ + obj2) - col_ind.append(beg_ + obj1) - row_ind.append(beg_ + obj1) - col_ind.append(beg_ + obj2) - beg_ += nb_obj - - # both ends of a line are connected together (if line is connected) - for q_id in range(self.n_line): - if self.line_status[q_id]: - # if powerline is connected connect both its side - row_ind.append(self.line_or_pos_topo_vect[q_id]) - col_ind.append(self.line_ex_pos_topo_vect[q_id]) - row_ind.append(self.line_ex_pos_topo_vect[q_id]) - col_ind.append(self.line_or_pos_topo_vect[q_id]) - row_ind = np.array(row_ind).astype(dt_int) - col_ind = np.array(col_ind).astype(dt_int) - if not as_csr_matrix: - self._connectivity_matrix_ = np.zeros( - shape=(self.dim_topo, self.dim_topo), dtype=dt_float - ) - self._connectivity_matrix_[row_ind.T, col_ind] = 1.0 - else: - data = np.ones(row_ind.shape[0], dtype=dt_float) - self._connectivity_matrix_ = csr_matrix( - (data, (row_ind, col_ind)), - shape=(self.dim_topo, self.dim_topo), - dtype=dt_float, - ) + need_build_mat = (self._connectivity_matrix_ is None or + isinstance(self._connectivity_matrix_, csr_matrix) and not as_csr_matrix or + ( + (not isinstance(self._connectivity_matrix_, csr_matrix)) + and as_csr_matrix + ) + ) + if need_build_mat : + self._aux_build_conn_mat(as_csr_matrix) return self._connectivity_matrix_ def _aux_fun_get_bus(self): """see in bus_connectivity matrix""" - bus_or = self.topo_vect[self.line_or_pos_topo_vect] - bus_ex = self.topo_vect[self.line_ex_pos_topo_vect] + cls = type(self) + bus_or = self.topo_vect[cls.line_or_pos_topo_vect] + bus_ex = self.topo_vect[cls.line_ex_pos_topo_vect] connected = (bus_or > 0) & (bus_ex > 0) bus_or = bus_or[connected] bus_ex = bus_ex[connected] - bus_or = self.line_or_to_subid[connected] + (bus_or - 1) * self.n_sub - bus_ex = self.line_ex_to_subid[connected] + (bus_ex - 1) * self.n_sub + # bus_or = self.line_or_to_subid[connected] + (bus_or - 1) * self.n_sub + # bus_ex = self.line_ex_to_subid[connected] + (bus_ex - 1) * self.n_sub + bus_or = cls.local_bus_to_global(bus_or, cls.line_or_to_subid[connected]) + bus_ex = cls.local_bus_to_global(bus_ex, cls.line_ex_to_subid[connected]) unique_bus = np.unique(np.concatenate((bus_or, bus_ex))) unique_bus = np.sort(unique_bus) nb_bus = unique_bus.shape[0] return nb_bus, unique_bus, bus_or, bus_ex - def bus_connectivity_matrix(self, as_csr_matrix=False, return_lines_index=False): + def bus_connectivity_matrix(self, + as_csr_matrix: bool=False, + return_lines_index: bool=False) -> Tuple[Union[np.ndarray, csr_matrix], Optional[Tuple[np.ndarray, np.ndarray]]]: """ If we denote by `nb_bus` the total number bus of the powergrid (you can think of a "bus" being a "node" if you represent a powergrid as a graph [mathematical object, not a plot] with the lines @@ -1784,11 +1853,19 @@ def bus_connectivity_matrix(self, as_csr_matrix=False, return_lines_index=False) return_lines_index: ``bool`` Whether to also return the bus index associated to both side of each powerline. + ``False`` by default, meaning the indexes are not returned. Returns ------- res: ``numpy.ndarray``, shape: (nb_bus, nb_bus) dtype:float The bus connectivity matrix defined above. + + optional: + + - `lor_bus` : for each powerline, it gives the id (row / column of the matrix) + of the bus of the matrix to which its origin side is connected + - `lex_bus` : for each powerline, it gives the id (row / column of the matrix) + of the bus of the matrix to which its extremity side is connected Notes ------ @@ -1890,10 +1967,13 @@ def _get_bus_id(self, id_topo_vect, sub_id): """ bus_id = 1 * self.topo_vect[id_topo_vect] connected = bus_id > 0 - bus_id[connected] = sub_id[connected] + (bus_id[connected] - 1) * self.n_sub + # bus_id[connected] = sub_id[connected] + (bus_id[connected] - 1) * self.n_sub + bus_id[connected] = type(self).local_bus_to_global(bus_id[connected], sub_id[connected]) return bus_id, connected - def flow_bus_matrix(self, active_flow=True, as_csr_matrix=False): + def flow_bus_matrix(self, + active_flow: bool=True, + as_csr_matrix: bool=False) -> Tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray, np.ndarray]: """ A matrix of size "nb bus" "nb bus". Each row and columns represent a "bus" of the grid ("bus" is a power system word that for computer scientist means "nodes" if the powergrid is represented as a graph). @@ -1957,7 +2037,7 @@ def flow_bus_matrix(self, active_flow=True, as_csr_matrix=False): flow on the origin (or extremity) side of the powerline connecting bus `i` to bus `j` You can also know how much power - (total generation + total storage discharging - total load - total storage charging - ) + (total generation + total storage discharging - total load - total storage charging) is injected at each bus `i` by looking at the `i` th diagonal coefficient. @@ -1966,11 +2046,11 @@ def flow_bus_matrix(self, active_flow=True, as_csr_matrix=False): matrix. `flow_mat.sum(axis=1)` """ + cls = type(self) if self._is_done: flow_mat = csr_matrix((1,1), dtype=dt_float) if not as_csr_matrix: flow_mat = flow_mat.toarray() - cls = type(self) load_bus = np.zeros(cls.n_load, dtype=dt_int) prod_bus = np.zeros(cls.n_gen, dtype=dt_int) stor_bus = np.zeros(cls.n_storage, dtype=dt_int) @@ -1980,26 +2060,26 @@ def flow_bus_matrix(self, active_flow=True, as_csr_matrix=False): nb_bus, unique_bus, bus_or, bus_ex = self._aux_fun_get_bus() prod_bus, prod_conn = self._get_bus_id( - self.gen_pos_topo_vect, self.gen_to_subid + cls.gen_pos_topo_vect, cls.gen_to_subid ) load_bus, load_conn = self._get_bus_id( - self.load_pos_topo_vect, self.load_to_subid + cls.load_pos_topo_vect, cls.load_to_subid ) stor_bus, stor_conn = self._get_bus_id( - self.storage_pos_topo_vect, self.storage_to_subid + cls.storage_pos_topo_vect, cls.storage_to_subid ) lor_bus, lor_conn = self._get_bus_id( - self.line_or_pos_topo_vect, self.line_or_to_subid + cls.line_or_pos_topo_vect, cls.line_or_to_subid ) lex_bus, lex_conn = self._get_bus_id( - self.line_ex_pos_topo_vect, self.line_ex_to_subid + cls.line_ex_pos_topo_vect, cls.line_ex_to_subid ) - if type(self).shunts_data_available: + if cls.shunts_data_available: sh_bus = 1 * self._shunt_bus sh_bus[sh_bus > 0] = ( - self.shunt_to_subid[sh_bus > 0] * (sh_bus[sh_bus > 0] - 1) - + self.shunt_to_subid[sh_bus > 0] + cls.shunt_to_subid[sh_bus > 0] * (sh_bus[sh_bus > 0] - 1) + + cls.shunt_to_subid[sh_bus > 0] ) sh_conn = self._shunt_bus != -1 @@ -2019,15 +2099,15 @@ def flow_bus_matrix(self, active_flow=True, as_csr_matrix=False): or_vect = self.p_or ex_vect = self.p_ex stor_vect = self.storage_power - if type(self).shunts_data_available: + if cls.shunts_data_available: sh_vect = self._shunt_p else: prod_vect = self.gen_q load_vect = self.load_q or_vect = self.q_or ex_vect = self.q_ex - stor_vect = np.zeros(self.n_storage, dtype=dt_float) - if type(self).shunts_data_available: + stor_vect = np.zeros(cls.n_storage, dtype=dt_float) + if cls.shunts_data_available: sh_vect = self._shunt_q nb_lor = lor_conn.sum() @@ -2068,7 +2148,7 @@ def flow_bus_matrix(self, active_flow=True, as_csr_matrix=False): ) data[bus_stor] -= map_mat.dot(stor_vect[stor_conn]) - if type(self).shunts_data_available: + if cls.shunts_data_available: # handle shunts nb_shunt = sh_conn.sum() if nb_shunt: @@ -2177,6 +2257,20 @@ def get_energy_graph(self) -> networkx.Graph: Convert this observation as a networkx graph. This graph is the graph "seen" by "the electron" / "the energy" of the power grid. + .. versionchanged:: 1.10.0 + Addition of the attribute `local_bus_id` and `global_bus_id` for the nodes of the returned graph. + + `local_bus_id` give the local bus id (from 1 to `obs.n_busbar_per_sub`) id of the + bus represented by this node. + + `global_bus_id` give the global bus id (from 0 to `obs.n_busbar_per_sub * obs.n_sub - 1`) id of the + bus represented by this node. + + Addition of the attribute `global_bus_or` and `global_bus_ex` for the edges of the returned graph. + + These provides the global id of the `origin` / `ext` side to which powerline(s) represented by + this edge is (are) connected. + Notes ------ The resulting graph is "frozen" this means that you cannot add / remove attribute on nodes or edges, nor add / @@ -2184,7 +2278,7 @@ def get_energy_graph(self) -> networkx.Graph: This graphs has the following properties: - - it counts as many nodes as the number of buses of the grid + - it counts as many nodes as the number of buses of the grid (so it has a dynamic size !) - it counts less edges than the number of lines of the grid (two lines connecting the same buses are "merged" into one single edge - this is the case for parallel line, that are hence "merged" into the same edge) - nodes (represents "buses" of the grid) have attributes: @@ -2195,9 +2289,14 @@ def get_energy_graph(self) -> networkx.Graph: - `v`: the voltage magnitude at this node - `cooldown`: how much longer you need to wait before being able to merge / split or change this node - 'sub_id': the id of the substation to which it is connected (typically between `0` and `obs.n_sub - 1`) - - (optional) `theta`: the voltage angle (in degree) at this nodes + - 'local_bus_id': the local bus id (from 1 to `obs.n_busbar_per_sub`) of the bus represented by this node + (new in version 1.10.0) + - 'global_bus_id': the global bus id (from 0 to `obs.n_busbar_per_sub * obs.n_sub - 1`) + of the bus represented by this node + (new in version 1.10.0) - `cooldown` : the time you need to wait (in number of steps) before being able to act on the substation to which this bus is connected. + - (optional) `theta`: the voltage angle (in degree) at this nodes - edges have attributes too (in this modeling an edge might represent more than one powerline, all parallel powerlines are represented by the same edge): @@ -2216,16 +2315,26 @@ def get_energy_graph(self) -> networkx.Graph: - `p`: active power injected at the "or" side (equal to p_or) (in MW) - `v_or`: voltage magnitude at the "or" bus (in kV) - `v_ex`: voltage magnitude at the "ex" bus (in kV) - - (optional) `theta_or`: voltage angle at the "or" bus (in deg) - - (optional) `theta_ex`: voltage angle at the "ex" bus (in deg) - `time_next_maintenance`: see :attr:`BaseObservation.time_next_maintenance` (min over all powerline) - `duration_next_maintenance` see :attr:`BaseObservation.duration_next_maintenance` (max over all powerlines) - `sub_id_or`: id of the substation of the "or" side of the powerlines - `sub_id_ex`: id of the substation of the "ex" side of the powerlines - `node_id_or`: id of the node (in this graph) of the "or" side of the powergraph - `node_id_ex`: id of the node (in this graph) of the "ex" side of the powergraph - - `bus_or`: on which bus [1 or 2] is this powerline connected to at its "or" substation - - `bus_ex`: on which bus [1 or 2] is this powerline connected to at its "ex" substation + - `bus_or`: on which bus [1 or 2 or 3, etc.] is this powerline connected to at its "or" substation + (this is the local id of the bus) + - `bus_ex`: on which bus [1 or 2 or 3, etc.] is this powerline connected to at its "ex" substation + (this is the local id of the bus) + - 'global_bus_or': the global bus id (from 0 to `obs.n_busbar_per_sub * obs.n_sub - 1`) + of the bus to which the origin side of the line(s) represented by this edge + is (are) connected + (new in version 1.10.0) + - 'global_bus_ex': the global bus id (from 0 to `obs.n_busbar_per_sub * obs.n_sub - 1`) + of the bus to which the ext side of the line(s) represented by this edge + is (are) connected + (new in version 1.10.0) + - (optional) `theta_or`: voltage angle at the "or" bus (in deg) + - (optional) `theta_ex`: voltage angle at the "ex" bus (in deg) .. danger:: **IMPORTANT NOTE** edges represents "fusion" of 1 or more powerlines. This graph is intended to be @@ -2316,6 +2425,10 @@ def get_energy_graph(self) -> networkx.Graph: bus_subid = np.zeros(mat_p.shape[0], dtype=dt_int) bus_subid[lor_bus[self.line_status]] = cls.line_or_to_subid[self.line_status] bus_subid[lex_bus[self.line_status]] = cls.line_ex_to_subid[self.line_status] + loc_bus_id = np.zeros(mat_p.shape[0], dtype=int) + loc_bus_id[lor_bus[self.line_status]] = self.topo_vect[cls.line_or_pos_topo_vect[self.line_status]] + loc_bus_id[lex_bus[self.line_status]] = self.topo_vect[cls.line_ex_pos_topo_vect[self.line_status]] + glob_bus_id = cls.local_bus_to_global(loc_bus_id, bus_subid) if self.support_theta: bus_theta[lor_bus[self.line_status]] = self.theta_or[self.line_status] bus_theta[lex_bus[self.line_status]] = self.theta_ex[self.line_status] @@ -2355,7 +2468,14 @@ def get_energy_graph(self) -> networkx.Graph: networkx.set_node_attributes(graph, {el: self.time_before_cooldown_sub[val] for el, val in enumerate(bus_subid)}, "cooldown") - + # add local_id and global_id as attribute to the node of this graph + networkx.set_node_attributes( + graph, {el: val for el, val in enumerate(loc_bus_id)}, "local_bus_id" + ) + networkx.set_node_attributes( + graph, {el: val for el, val in enumerate(glob_bus_id)}, "global_bus_id" + ) + # add the edges attributes self._add_edges_multi(self.p_or, self.p_ex, "p", lor_bus, lex_bus, graph) self._add_edges_multi(self.q_or, self.q_ex, "q", lor_bus, lex_bus, graph) @@ -2415,16 +2535,25 @@ def get_energy_graph(self) -> networkx.Graph: self.line_ex_bus, "bus_ex", lor_bus, lex_bus, graph ) + self._add_edges_simple( + glob_bus_id[lor_bus], + "global_bus_or", lor_bus, lex_bus, graph + ) + self._add_edges_simple( + glob_bus_id[lex_bus], + "global_bus_ex", lor_bus, lex_bus, graph + ) # extra layer of security: prevent accidental modification of this graph networkx.freeze(graph) return graph def _aux_get_connected_buses(self): - res = np.full(2 * self.n_sub, fill_value=False) - global_bus = type(self).local_bus_to_global(self.topo_vect, - self._topo_vect_to_sub) - res[np.unique(global_bus[global_bus != -1])] = True + cls = type(self) + res = np.full(cls.n_busbar_per_sub * cls.n_sub, fill_value=False) + global_bus = cls.local_bus_to_global(self.topo_vect, + cls._topo_vect_to_sub) + res[global_bus[global_bus != -1]] = True return res def _aux_add_edges(self, @@ -2454,6 +2583,7 @@ def _aux_add_edges(self, li_el_edges[ed_num][-1][prop_nm] = prop_vect[el_id] ed_num += 1 graph.add_edges_from(li_el_edges) + return li_el_edges def _aux_add_el_to_comp_graph(self, graph, @@ -2489,30 +2619,37 @@ def _aux_add_el_to_comp_graph(self, el_connected = np.array(el_global_bus) >= 0 for el_id in range(nb_el): li_el_node[el_id][-1]["connected"] = el_connected[el_id] + li_el_node[el_id][-1]["local_bus"] = el_bus[el_id] + li_el_node[el_id][-1]["global_bus"] = el_global_bus[el_id] if nodes_prop is not None: for el_id in range(nb_el): for prop_nm, prop_vect in nodes_prop: li_el_node[el_id][-1][prop_nm] = prop_vect[el_id] - graph.add_nodes_from(li_el_node) - graph.graph[f"{el_name}_nodes_id"] = el_ids if el_bus is None and el_to_sub_id is None: + graph.add_nodes_from(li_el_node) + graph.graph[f"{el_name}_nodes_id"] = el_ids return el_ids # add the edges - self._aux_add_edges(el_ids, - cls, - el_global_bus, - nb_el, - el_connected, - el_name, - edges_prop, - graph) + li_el_edges = self._aux_add_edges(el_ids, + cls, + el_global_bus, + nb_el, + el_connected, + el_name, + edges_prop, + graph) + for el_id, (el_node_id, edege_id, *_) in enumerate(li_el_edges): + li_el_node[el_id][-1]["bus_node_id"] = edege_id + + graph.add_nodes_from(li_el_node) + graph.graph[f"{el_name}_nodes_id"] = el_ids return el_ids def _aux_add_buses(self, graph, cls, first_id): - bus_ids = first_id + np.arange(2 * cls.n_sub) + bus_ids = first_id + np.arange(cls.n_busbar_per_sub * cls.n_sub) conn_bus = self._aux_get_connected_buses() bus_li = [ (bus_ids[bus_id], @@ -2522,7 +2659,7 @@ def _aux_add_buses(self, graph, cls, first_id): "type": "bus", "connected": conn_bus[bus_id]} ) - for bus_id in range(2 * cls.n_sub) + for bus_id in range(cls.n_busbar_per_sub * cls.n_sub) ] graph.add_nodes_from(bus_li) edge_bus_li = [(bus_id, @@ -2622,15 +2759,32 @@ def _aux_add_edge_line_side(self, ] if theta_vect is not None: edges_prop.append(("theta", theta_vect)) - self._aux_add_edges(line_node_ids, - cls, - global_bus, - cls.n_line, - conn_, - "line", - edges_prop, - graph) - + res = self._aux_add_edges(line_node_ids, + cls, + global_bus, + cls.n_line, + conn_, + "line", + edges_prop, + graph) + return res + + def _aux_add_local_global(self, cls, graph, lin_ids, el_loc_bus, xxx_subid, side): + el_global_bus = cls.local_bus_to_global(el_loc_bus, + xxx_subid) + dict_ = {} + for el_node_id, loc_bus in zip(lin_ids, el_loc_bus): + dict_[el_node_id] = loc_bus + networkx.set_node_attributes( + graph, dict_, f"local_bus_{side}" + ) + dict_ = {} + for el_node_id, glob_bus in zip(lin_ids, el_global_bus): + dict_[el_node_id] = glob_bus + networkx.set_node_attributes( + graph, dict_, f"global_bus_{side}" + ) + def _aux_add_lines(self, graph, cls, first_id): nodes_prop = [("rho", self.rho), ("connected", self.line_status), @@ -2639,6 +2793,7 @@ def _aux_add_lines(self, graph, cls, first_id): ("time_next_maintenance", self.time_next_maintenance), ("duration_next_maintenance", self.duration_next_maintenance), ] + # only add the nodes, not the edges right now lin_ids = self._aux_add_el_to_comp_graph(graph, first_id, @@ -2650,32 +2805,47 @@ def _aux_add_lines(self, graph, cls, first_id): nodes_prop=nodes_prop, edges_prop=None ) + self._aux_add_local_global(cls, graph, lin_ids, self.line_or_bus, cls.line_or_to_subid, "or") + self._aux_add_local_global(cls, graph, lin_ids, self.line_ex_bus, cls.line_ex_to_subid, "ex") # add "or" edges - self._aux_add_edge_line_side(cls, - graph, - self.line_or_bus, - cls.line_or_to_subid, - lin_ids, - "or", - self.p_or, - self.q_or, - self.v_or, - self.a_or, - self.theta_or if self.support_theta else None) + li_el_edges_or = self._aux_add_edge_line_side(cls, + graph, + self.line_or_bus, + cls.line_or_to_subid, + lin_ids, + "or", + self.p_or, + self.q_or, + self.v_or, + self.a_or, + self.theta_or if self.support_theta else None) + dict_or = {} + for el_id, (el_node_id, edege_id, *_) in enumerate(li_el_edges_or): + dict_or[el_node_id] = edege_id + networkx.set_node_attributes( + graph, dict_or, "bus_node_id_or" + ) # add "ex" edges - self._aux_add_edge_line_side(cls, - graph, - self.line_ex_bus, - cls.line_ex_to_subid, - lin_ids, - "ex", - self.p_ex, - self.q_ex, - self.v_ex, - self.a_ex, - self.theta_ex if self.support_theta else None) + li_el_edges_ex = self._aux_add_edge_line_side(cls, + graph, + self.line_ex_bus, + cls.line_ex_to_subid, + lin_ids, + "ex", + self.p_ex, + self.q_ex, + self.v_ex, + self.a_ex, + self.theta_ex if self.support_theta else None) + dict_ex = {} + for el_id, (el_node_id, edege_id, *_) in enumerate(li_el_edges_ex): + dict_ex[el_node_id] = edege_id + networkx.set_node_attributes( + graph, dict_ex, "bus_node_id_ex" + ) + return lin_ids def _aux_add_shunts(self, graph, cls, first_id): @@ -2702,7 +2872,8 @@ def get_elements_graph(self) -> networkx.DiGraph: """This function returns the "elements graph" as a networkx object. .. seealso:: - This object is extensively described in the documentation, see :ref:`elmnt-graph-gg` for more information. + This object is extensively described in the documentation, + see :ref:`elmnt-graph-gg` for more information. Basically, each "element" of the grid (element = a substation, a bus, a load, a generator, a powerline, a storate unit or a shunt) is represented by a node in this graph. @@ -2752,6 +2923,7 @@ def get_elements_graph(self) -> networkx.DiGraph: ------- networkx.DiGraph The "elements graph", see :ref:`elmnt-graph-gg` . + """ cls = type(self) @@ -2819,7 +2991,7 @@ def get_elements_graph(self) -> networkx.DiGraph: networkx.freeze(graph) return graph - def get_forecasted_inj(self, time_step=1): + def get_forecasted_inj(self, time_step:int =1) -> np.ndarray: """ This function allows you to retrieve directly the "forecast" injections for the step `time_step`. @@ -2848,11 +3020,12 @@ def get_forecasted_inj(self, time_step=1): time_step ) ) + cls = type(self) t, a = self._forecasted_inj[time_step] - prod_p_f = np.full(self.n_gen, fill_value=np.NaN, dtype=dt_float) - prod_v_f = np.full(self.n_gen, fill_value=np.NaN, dtype=dt_float) - load_p_f = np.full(self.n_load, fill_value=np.NaN, dtype=dt_float) - load_q_f = np.full(self.n_load, fill_value=np.NaN, dtype=dt_float) + prod_p_f = np.full(cls.n_gen, fill_value=np.NaN, dtype=dt_float) + prod_v_f = np.full(cls.n_gen, fill_value=np.NaN, dtype=dt_float) + load_p_f = np.full(cls.n_load, fill_value=np.NaN, dtype=dt_float) + load_q_f = np.full(cls.n_load, fill_value=np.NaN, dtype=dt_float) if "prod_p" in a["injection"]: prod_p_f = a["injection"]["prod_p"] @@ -2872,7 +3045,7 @@ def get_forecasted_inj(self, time_step=1): load_q_f[tmp_arg] = self.load_q[tmp_arg] return prod_p_f, prod_v_f, load_p_f, load_q_f - def get_time_stamp(self): + def get_time_stamp(self) -> datetime.datetime: """ Get the time stamp of the current observation as a `datetime.datetime` object """ @@ -2885,7 +3058,10 @@ def get_time_stamp(self): ) return res - def simulate(self, action, time_step=1): + def simulate(self, action : "grid2op.Action.BaseAction", time_step:int=1) -> Tuple["BaseObservation", + float, + bool, + STEP_INFO_TYPING]: """ This method is used to simulate the effect of an action on a forecast powergrid state. This forecast state is built upon the current observation. @@ -3158,7 +3334,7 @@ def simulate(self, action, time_step=1): sim_obs._update_internal_env_params(self._obs_env) return (sim_obs, *rest) # parentheses are needed for python 3.6 at least. - def copy(self): + def copy(self) -> Self: """ INTERNAL @@ -3198,9 +3374,9 @@ def copy(self): return res @property - def line_or_bus(self): + def line_or_bus(self) -> np.ndarray: """ - Retrieve the busbar at which each origin end of powerline is connected. + Retrieve the busbar at which each origin side of powerline is connected. The result follow grid2op convention: @@ -3220,9 +3396,9 @@ def line_or_bus(self): return res @property - def line_ex_bus(self): + def line_ex_bus(self) -> np.ndarray: """ - Retrieve the busbar at which each extremity end of powerline is connected. + Retrieve the busbar at which each extremity side of powerline is connected. The result follow grid2op convention: @@ -3242,7 +3418,7 @@ def line_ex_bus(self): return res @property - def gen_bus(self): + def gen_bus(self) -> np.ndarray: """ Retrieve the busbar at which each generator is connected. @@ -3264,7 +3440,7 @@ def gen_bus(self): return res @property - def load_bus(self): + def load_bus(self) -> np.ndarray: """ Retrieve the busbar at which each load is connected. @@ -3286,7 +3462,7 @@ def load_bus(self): return res @property - def storage_bus(self): + def storage_bus(self) -> np.ndarray: """ Retrieve the busbar at which each storage unit is connected. @@ -3308,7 +3484,7 @@ def storage_bus(self): return res @property - def prod_p(self): + def prod_p(self) -> np.ndarray: """ As of grid2op version 1.5.0, for better consistency, the "prod_p" attribute has been renamed "gen_p", see the doc of :attr:`BaseObservation.gen_p` for more information. @@ -3323,7 +3499,7 @@ def prod_p(self): return self.gen_p @property - def prod_q(self): + def prod_q(self) -> np.ndarray: """ As of grid2op version 1.5.0, for better consistency, the "prod_q" attribute has been renamed "gen_q", see the doc of :attr:`BaseObservation.gen_q` for more information. @@ -3338,7 +3514,7 @@ def prod_q(self): return self.gen_q @property - def prod_v(self): + def prod_v(self) -> np.ndarray: """ As of grid2op version 1.5.0, for better consistency, the "prod_v" attribute has been renamed "gen_v", see the doc of :attr:`BaseObservation.gen_v` for more information. @@ -3352,7 +3528,7 @@ def prod_v(self): """ return self.gen_v - def sub_topology(self, sub_id): + def sub_topology(self, sub_id) -> np.ndarray: """ Returns the topology of the given substation. @@ -3526,7 +3702,111 @@ def to_dict(self): return self._dictionnarized - def add_act(self, act, issue_warn=True): + def _aux_add_act_set_line_status(self, cls, cls_act, act, res, issue_warn): + reco_powerline = act.line_set_status + if "set_bus" in cls_act.authorized_keys: + line_ex_set_bus = act.line_ex_set_bus + line_or_set_bus = act.line_or_set_bus + else: + line_ex_set_bus = np.zeros(cls.n_line, dtype=dt_int) + line_or_set_bus = np.zeros(cls.n_line, dtype=dt_int) + error_no_bus_set = ( + "You reconnected a powerline with your action but did not specify on which bus " + "to reconnect both its end. This behaviour, also perfectly fine for an environment " + "will not be accurate in the method obs + act. Consult the documentation for more " + "information. Problem arose for powerlines with id {}" + ) + + tmp = ( + (reco_powerline == 1) + & (line_ex_set_bus <= 0) + & (res.topo_vect[cls.line_ex_pos_topo_vect] == -1) + ) + if tmp.any(): + id_issue_ex = np.nonzero(tmp)[0] + if issue_warn: + warnings.warn(error_no_bus_set.format(id_issue_ex)) + if "set_bus" in cls_act.authorized_keys: + # assign 1 in the bus in this case + act.line_ex_set_bus = [(el, 1) for el in id_issue_ex] + tmp = ( + (reco_powerline == 1) + & (line_or_set_bus <= 0) + & (res.topo_vect[cls.line_or_pos_topo_vect] == -1) + ) + if tmp.any(): + id_issue_or = np.nonzero(tmp)[0] + if issue_warn: + warnings.warn(error_no_bus_set.format(id_issue_or)) + if "set_bus" in cls_act.authorized_keys: + # assign 1 in the bus in this case + act.line_or_set_bus = [(el, 1) for el in id_issue_or] + + def _aux_add_act_set_line_status2(self, cls, cls_act, act, res, issue_warn): + disco_line = (act.line_set_status == -1) & res.line_status + res.topo_vect[cls.line_or_pos_topo_vect[disco_line]] = -1 + res.topo_vect[cls.line_ex_pos_topo_vect[disco_line]] = -1 + res.line_status[disco_line] = False + + reco_line = (act.line_set_status >= 1) & (~res.line_status) + # i can do that because i already "fixed" the action to have it put 1 in case it + # bus were not provided + if "set_bus" in cls_act.authorized_keys: + # I assign previous bus (because it could have been modified) + res.topo_vect[ + cls.line_or_pos_topo_vect[reco_line] + ] = act.line_or_set_bus[reco_line] + res.topo_vect[ + cls.line_ex_pos_topo_vect[reco_line] + ] = act.line_ex_set_bus[reco_line] + else: + # I assign one (action do not allow me to modify the bus) + res.topo_vect[cls.line_or_pos_topo_vect[reco_line]] = 1 + res.topo_vect[cls.line_ex_pos_topo_vect[reco_line]] = 1 + + res.line_status[reco_line] = True + + def _aux_add_act_change_line_status2(self, cls, cls_act, act, res, issue_warn): + disco_line = act.line_change_status & res.line_status + reco_line = act.line_change_status & (~res.line_status) + + # handle disconnected powerlines + res.topo_vect[cls.line_or_pos_topo_vect[disco_line]] = -1 + res.topo_vect[cls.line_ex_pos_topo_vect[disco_line]] = -1 + res.line_status[disco_line] = False + + # handle reconnected powerlines + if reco_line.any(): + if "set_bus" in cls_act.authorized_keys: + line_ex_set_bus = 1 * act.line_ex_set_bus + line_or_set_bus = 1 * act.line_or_set_bus + else: + line_ex_set_bus = np.zeros(cls.n_line, dtype=dt_int) + line_or_set_bus = np.zeros(cls.n_line, dtype=dt_int) + + if issue_warn and ( + (line_or_set_bus[reco_line] == 0).any() + or (line_ex_set_bus[reco_line] == 0).any() + ): + warnings.warn( + 'A powerline has been reconnected with a "change_status" action without ' + "specifying on which bus it was supposed to be reconnected. This is " + "perfectly fine in regular grid2op environment, but this behaviour " + "cannot be properly implemented with the only information in the " + "observation. Please see the documentation for more information." + ) + line_or_set_bus[reco_line & (line_or_set_bus == 0)] = 1 + line_ex_set_bus[reco_line & (line_ex_set_bus == 0)] = 1 + + res.topo_vect[cls.line_or_pos_topo_vect[reco_line]] = line_or_set_bus[ + reco_line + ] + res.topo_vect[cls.line_ex_pos_topo_vect[reco_line]] = line_ex_set_bus[ + reco_line + ] + res.line_status[reco_line] = True + + def add_act(self, act : "grid2op.Action.BaseAction", issue_warn=True) -> Self: """ Easier access to the impact on the observation if an action were applied. @@ -3615,8 +3895,11 @@ def add_act(self, act, issue_warn=True): if not isinstance(act, BaseAction): raise RuntimeError("You can only add actions to observation at the moment") + cls = type(self) + cls_act = type(act) + act = copy.deepcopy(act) - res = type(self)() + res = cls() res.set_game_over(env=None) res.topo_vect[:] = self.topo_vect @@ -3630,138 +3913,52 @@ def add_act(self, act, issue_warn=True): ) # if a powerline has been reconnected without specific bus, i issue a warning - if "set_line_status" in act.authorized_keys: - reco_powerline = act.line_set_status - if "set_bus" in act.authorized_keys: - line_ex_set_bus = act.line_ex_set_bus - line_or_set_bus = act.line_or_set_bus - else: - line_ex_set_bus = np.zeros(res.n_line, dtype=dt_int) - line_or_set_bus = np.zeros(res.n_line, dtype=dt_int) - error_no_bus_set = ( - "You reconnected a powerline with your action but did not specify on which bus " - "to reconnect both its end. This behaviour, also perfectly fine for an environment " - "will not be accurate in the method obs + act. Consult the documentation for more " - "information. Problem arose for powerlines with id {}" - ) - - tmp = ( - (reco_powerline == 1) - & (line_ex_set_bus <= 0) - & (res.topo_vect[self.line_ex_pos_topo_vect] == -1) - ) - if tmp.any(): - id_issue_ex = np.where(tmp)[0] - if issue_warn: - warnings.warn(error_no_bus_set.format(id_issue_ex)) - if "set_bus" in act.authorized_keys: - # assign 1 in the bus in this case - act.line_ex_set_bus = [(el, 1) for el in id_issue_ex] - tmp = ( - (reco_powerline == 1) - & (line_or_set_bus <= 0) - & (res.topo_vect[self.line_or_pos_topo_vect] == -1) - ) - if tmp.any(): - id_issue_or = np.where(tmp)[0] - if issue_warn: - warnings.warn(error_no_bus_set.format(id_issue_or)) - if "set_bus" in act.authorized_keys: - # assign 1 in the bus in this case - act.line_or_set_bus = [(el, 1) for el in id_issue_or] - + if "set_line_status" in cls_act.authorized_keys: + self._aux_add_act_set_line_status(cls, cls_act, act, res, issue_warn) + # topo vect - if "set_bus" in act.authorized_keys: + if "set_bus" in cls_act.authorized_keys: res.topo_vect[act.set_bus != 0] = act.set_bus[act.set_bus != 0] - if "change_bus" in act.authorized_keys: + if "change_bus" in cls_act.authorized_keys: do_change_bus_on = act.change_bus & ( res.topo_vect > 0 ) # change bus of elements that were on res.topo_vect[do_change_bus_on] = 3 - res.topo_vect[do_change_bus_on] # topo vect: reco of powerline that should be - res.line_status = (res.topo_vect[self.line_or_pos_topo_vect] >= 1) & ( - res.topo_vect[self.line_ex_pos_topo_vect] >= 1 + res.line_status = (res.topo_vect[cls.line_or_pos_topo_vect] >= 1) & ( + res.topo_vect[cls.line_ex_pos_topo_vect] >= 1 ) # powerline status - if "set_line_status" in act.authorized_keys: - disco_line = (act.line_set_status == -1) & res.line_status - res.topo_vect[res.line_or_pos_topo_vect[disco_line]] = -1 - res.topo_vect[res.line_ex_pos_topo_vect[disco_line]] = -1 - res.line_status[disco_line] = False - - reco_line = (act.line_set_status >= 1) & (~res.line_status) - # i can do that because i already "fixed" the action to have it put 1 in case it - # bus were not provided - if "set_bus" in act.authorized_keys: - # I assign previous bus (because it could have been modified) - res.topo_vect[ - res.line_or_pos_topo_vect[reco_line] - ] = act.line_or_set_bus[reco_line] - res.topo_vect[ - res.line_ex_pos_topo_vect[reco_line] - ] = act.line_ex_set_bus[reco_line] - else: - # I assign one (action do not allow me to modify the bus) - res.topo_vect[res.line_or_pos_topo_vect[reco_line]] = 1 - res.topo_vect[res.line_ex_pos_topo_vect[reco_line]] = 1 - - res.line_status[reco_line] = True - - if "change_line_status" in act.authorized_keys: - disco_line = act.line_change_status & res.line_status - reco_line = act.line_change_status & (~res.line_status) - - # handle disconnected powerlines - res.topo_vect[res.line_or_pos_topo_vect[disco_line]] = -1 - res.topo_vect[res.line_ex_pos_topo_vect[disco_line]] = -1 - res.line_status[disco_line] = False - - # handle reconnected powerlines - if reco_line.any(): - if "set_bus" in act.authorized_keys: - line_ex_set_bus = 1 * act.line_ex_set_bus - line_or_set_bus = 1 * act.line_or_set_bus - else: - line_ex_set_bus = np.zeros(res.n_line, dtype=dt_int) - line_or_set_bus = np.zeros(res.n_line, dtype=dt_int) + if "set_line_status" in cls_act.authorized_keys: + self._aux_add_act_set_line_status2(cls, cls_act, act, res, issue_warn) + + if "change_line_status" in cls_act.authorized_keys: + self._aux_add_act_change_line_status2(cls, cls_act, act, res, issue_warn) - if issue_warn and ( - (line_or_set_bus[reco_line] == 0).any() - or (line_ex_set_bus[reco_line] == 0).any() - ): - warnings.warn( - 'A powerline has been reconnected with a "change_status" action without ' - "specifying on which bus it was supposed to be reconnected. This is " - "perfectly fine in regular grid2op environment, but this behaviour " - "cannot be properly implemented with the only information in the " - "observation. Please see the documentation for more information." - ) - line_or_set_bus[reco_line & (line_or_set_bus == 0)] = 1 - line_ex_set_bus[reco_line & (line_ex_set_bus == 0)] = 1 - - res.topo_vect[res.line_or_pos_topo_vect[reco_line]] = line_or_set_bus[ - reco_line - ] - res.topo_vect[res.line_ex_pos_topo_vect[reco_line]] = line_ex_set_bus[ - reco_line - ] - res.line_status[reco_line] = True - - if "redispatch" in act.authorized_keys: + if "redispatch" in cls_act.authorized_keys: redisp = act.redispatch - if (redisp != 0).any() and issue_warn: + if (np.abs(redisp) >= 1e-7).any() and issue_warn: warnings.warn( "You did redispatching on this action. Redispatching is heavily transformed " "by the environment (consult the documentation about the modeling of the " "generators for example) so we will not even try to mimic this here." ) - if "set_storage" in act.authorized_keys: + if "set_storage" in cls_act.authorized_keys: storage_p = act.storage_p - if (storage_p != 0).any() and issue_warn: + if (np.abs(storage_p) >= 1e-7).any() and issue_warn: + warnings.warn( + "You did action on storage units in this action. This implies performing some " + "redispatching which is heavily transformed " + "by the environment (consult the documentation about the modeling of the " + "generators for example) so we will not even try to mimic this here." + ) + if "curtail" in cls_act.authorized_keys: + curt = act.curtail + if (np.abs(curt + 1) >= 1e-7).any() and issue_warn: # curtail == -1. warnings.warn( "You did action on storage units in this action. This implies performing some " "redispatching which is heavily transformed " @@ -3770,7 +3967,7 @@ def add_act(self, act, issue_warn=True): ) return res - def __add__(self, act): + def __add__(self, act: "grid2op.Action.BaseAction") -> Self: from grid2op.Action import BaseAction if isinstance(act, BaseAction): @@ -3780,7 +3977,7 @@ def __add__(self, act): ) @property - def thermal_limit(self): + def thermal_limit(self) -> np.ndarray: """ Return the thermal limit of the powergrid, given in Amps (A) @@ -3801,7 +3998,7 @@ def thermal_limit(self): return res @property - def curtailment_mw(self): + def curtailment_mw(self) -> np.ndarray: """ return the curtailment, expressed in MW rather than in ratio of pmax. @@ -3820,7 +4017,7 @@ def curtailment_mw(self): return self.curtailment * self.gen_pmax @property - def curtailment_limit_mw(self): + def curtailment_limit_mw(self) -> np.ndarray: """ return the limit of production of a generator in MW rather in ratio @@ -3838,7 +4035,7 @@ def curtailment_limit_mw(self): """ return self.curtailment_limit * self.gen_pmax - def _update_attr_backend(self, backend): + def _update_attr_backend(self, backend: "grid2op.Backend.Backend") -> None: """This function updates the attribute of the observation that depends only on the backend. @@ -3846,8 +4043,10 @@ def _update_attr_backend(self, backend): ---------- backend : The backend from which to update the observation + """ - + cls = type(self) + self.line_status[:] = backend.get_line_status() self.topo_vect[:] = backend.get_topo_vect() @@ -3860,15 +4059,15 @@ def _update_attr_backend(self, backend): self.rho[:] = backend.get_relative_flow().astype(dt_float) # margin up and down - if type(self).redispatching_unit_commitment_availble: + if cls.redispatching_unit_commitment_availble: self.gen_margin_up[:] = np.minimum( - type(self).gen_pmax - self.gen_p, self.gen_max_ramp_up + cls.gen_pmax - self.gen_p, self.gen_max_ramp_up ) - self.gen_margin_up[type(self).gen_renewable] = 0.0 + self.gen_margin_up[cls.gen_renewable] = 0.0 self.gen_margin_down[:] = np.minimum( - self.gen_p - type(self).gen_pmin, self.gen_max_ramp_down + self.gen_p - cls.gen_pmin, self.gen_max_ramp_down ) - self.gen_margin_down[type(self).gen_renewable] = 0.0 + self.gen_margin_down[cls.gen_renewable] = 0.0 # because of the slack, sometimes it's negative... # see https://github.com/rte-france/Grid2Op/issues/313 @@ -3879,7 +4078,7 @@ def _update_attr_backend(self, backend): self.gen_margin_down[:] = 0.0 # handle shunts (if avaialble) - if type(self).shunts_data_available: + if cls.shunts_data_available: sh_p, sh_q, sh_v, sh_bus = backend.shunt_info() self._shunt_p[:] = sh_p self._shunt_q[:] = sh_q @@ -3903,7 +4102,7 @@ def _update_attr_backend(self, backend): self.gen_theta[:] = 0. self.storage_theta[:] = 0. - def _update_internal_env_params(self, env): + def _update_internal_env_params(self, env: "grid2op.Environment.BaseEnv"): # this is only done if the env supports forecast # some parameters used for the "forecast env" # but not directly accessible in the observation @@ -3928,7 +4127,7 @@ def _update_internal_env_params(self, env): # (self._env_internal_params["opp_space_state"], # self._env_internal_params["opp_state"]) = env._oppSpace._get_state() - def _update_obs_complete(self, env, with_forecast=True): + def _update_obs_complete(self, env: "grid2op.Environment.BaseEnv", with_forecast:bool=True): """ update all the observation attributes as if it was a complete, fully observable and without noise observation @@ -4004,7 +4203,7 @@ def _update_obs_complete(self, env, with_forecast=True): self._update_alert(env) - def _update_forecast(self, env, with_forecast): + def _update_forecast(self, env: "grid2op.Environment.BaseEnv", with_forecast: bool) -> None: if not with_forecast: return @@ -4023,7 +4222,7 @@ def _update_forecast(self, env, with_forecast): self._env_internal_params = {} self._update_internal_env_params(env) - def _update_alarm(self, env): + def _update_alarm(self, env: "grid2op.Environment.BaseEnv"): if not (self.dim_alarms and env._has_attention_budget): return @@ -4038,7 +4237,7 @@ def _update_alarm(self, env): self.last_alarm[:] = env._attention_budget.last_successful_alarm_raised self.attention_budget[:] = env._attention_budget.current_budget - def _update_alert(self, env): + def _update_alert(self, env: "grid2op.Environment.BaseEnv"): self.active_alert[:] = env._last_alert self.time_since_last_alert[:] = env._time_since_last_alert self.alert_duration[:] = env._alert_duration @@ -4105,7 +4304,7 @@ def get_simulator(self) -> "grid2op.simulator.Simulator": self._obs_env.highres_sim_counter._HighResSimCounter__nb_highres_called = nb_highres_called return res - def _get_array_from_forecast(self, name): + def _get_array_from_forecast(self, name: str) -> np.ndarray: if len(self._forecasted_inj) <= 1: # self._forecasted_inj already embed the current step raise NoForecastAvailable("It appears this environment does not support any forecast at all.") @@ -4123,7 +4322,7 @@ def _get_array_from_forecast(self, name): res[h,:] = this_row return res - def _generate_forecasted_maintenance_for_simenv(self, nb_h: int): + def _generate_forecasted_maintenance_for_simenv(self, nb_h: int) -> np.ndarray: n_line = type(self).n_line res = np.full((nb_h, n_line), fill_value=False, dtype=dt_bool) for l_id in range(n_line): @@ -4243,7 +4442,7 @@ def get_forecast_env(self) -> "grid2op.Environment.Environment": maintenance = self._generate_forecasted_maintenance_for_simenv(prod_v.shape[0]) return self._make_env_from_arays(load_p, load_q, prod_p, prod_v, maintenance) - def get_forecast_arrays(self): + def get_forecast_arrays(self) -> Tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray]: """ This functions allows to retrieve (as numpy arrays) the values for all the loads / generators / maintenance for the forseable future (they are the forecast availble in :func:`BaseObservation.simulate` and @@ -4463,7 +4662,7 @@ def _make_env_from_arays(self, res.highres_sim_counter._HighResSimCounter__nb_highres_called = nb_highres_called return res - def change_forecast_parameters(self, params): + def change_forecast_parameters(self, params: "grid2op.Parameters.Parameters") -> None: """This function allows to change the parameters (see :class:`grid2op.Parameters.Parameters` for more information) that are used for the `obs.simulate()` and `obs.get_forecast_env()` method. @@ -4503,7 +4702,7 @@ def change_forecast_parameters(self, params): self._obs_env.change_parameters(params) self._obs_env._parameters = params - def update_after_reward(self, env): + def update_after_reward(self, env: "grid2op.Environment.BaseEnv") -> None: """Only called for the regular environment (so not available for :func:`BaseObservation.get_forecast_env` or :func:`BaseObservation.simulate`) @@ -4531,4 +4730,55 @@ def update_after_reward(self, env): return # update the was_alert_used_after_attack ! - self.was_alert_used_after_attack[:] = env._was_alert_used_after_attack \ No newline at end of file + self.was_alert_used_after_attack[:] = env._was_alert_used_after_attack + + def get_back_to_ref_state( + self, + storage_setpoint: float=0.5, + precision: int=5, + ) -> Dict[Literal["powerline", + "substation", + "redispatching", + "storage", + "curtailment"], + List["grid2op.Action.BaseAction"]]: + """ + Allows to retrieve the list of actions that needs to be performed + to get back the grid in the "reference" state (all elements connected + to busbar 1, no redispatching, no curtailment) + + + .. versionadded:: 1.10.0 + + This function uses the method of the underlying action_space used + for the forecasts. + + See :func:`grid2op.Action.SerializableActionSpace.get_back_to_ref_state` + for more information. + + Examples + -------- + + You can use it like this: + + .. code-block:: python + + import grid2op + + env_name = "l2rpn_case14_sandbox" + env = grid2op.make(env_name) + obs = env.reset(seed=1) + + # perform a random action + obs, reward, done, info = env.step(env.action_space.sample()) + assert not done # you might end up in a "done" state depending on the random action + + acts = obs.get_back_to_ref_state() + print(acts) + """ + if self.action_helper is None: + raise Grid2OpException("Trying to use this function when no action space is " + "is available.") + 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) diff --git a/grid2op/Observation/observationSpace.py b/grid2op/Observation/observationSpace.py index add75c631..8eeebd89a 100644 --- a/grid2op/Observation/observationSpace.py +++ b/grid2op/Observation/observationSpace.py @@ -93,7 +93,7 @@ def __init__( self.logger.disabled = True else: self.logger: logging.Logger = logger.getChild("grid2op_ObsSpace") - + self._init_observationClass = observationClass SerializableObservationSpace.__init__( self, gridobj, observationClass=observationClass ) @@ -283,7 +283,7 @@ def reactivate_forecast(self, env): if self.obs_env is not None : self.obs_env.close() self.obs_env = None - self._create_obs_env(env) + self._create_obs_env(env, self._init_observationClass) self.set_real_env_kwargs(env) self.with_forecast = True @@ -386,8 +386,8 @@ def change_reward(self, reward_func): self.obs_env._reward_helper.change_reward(reward_func) else: raise EnvError("Impossible to change the reward of the simulate " - "function when you cannot simulate (because the " - "backend could not be copied)") + "function when you cannot simulate (because the " + "backend could not be copied)") def set_thermal_limit(self, thermal_limit_a): if self.obs_env is not None: @@ -463,6 +463,7 @@ def _custom_deepcopy_for_copy(self, new_obj): super()._custom_deepcopy_for_copy(new_obj) # now fill my class + new_obj._init_observationClass = self._init_observationClass new_obj.with_forecast = self.with_forecast new_obj._simulate_parameters = copy.deepcopy(self._simulate_parameters) new_obj._reward_func = copy.deepcopy(self._reward_func) diff --git a/grid2op/Opponent/geometricOpponent.py b/grid2op/Opponent/geometricOpponent.py index 71253d4a7..ee0e23a00 100644 --- a/grid2op/Opponent/geometricOpponent.py +++ b/grid2op/Opponent/geometricOpponent.py @@ -109,7 +109,7 @@ def init( # Store attackable lines IDs self._lines_ids = [] for l_name in lines_attacked: - l_id = np.where(self.action_space.name_line == l_name) + l_id = np.nonzero(self.action_space.name_line == l_name) if len(l_id) and len(l_id[0]): self._lines_ids.append(l_id[0][0]) else: diff --git a/grid2op/Opponent/randomLineOpponent.py b/grid2op/Opponent/randomLineOpponent.py index f1c5ed256..da8ba3058 100644 --- a/grid2op/Opponent/randomLineOpponent.py +++ b/grid2op/Opponent/randomLineOpponent.py @@ -57,7 +57,7 @@ def init(self, partial_env, lines_attacked=[], **kwargs): # Store attackable lines IDs self._lines_ids = [] for l_name in lines_attacked: - l_id = np.where(self.action_space.name_line == l_name) + l_id = np.nonzero(self.action_space.name_line == l_name) if len(l_id) and len(l_id[0]): self._lines_ids.append(l_id[0][0]) else: diff --git a/grid2op/Opponent/weightedRandomOpponent.py b/grid2op/Opponent/weightedRandomOpponent.py index 35ad5f2be..c1298e1e1 100644 --- a/grid2op/Opponent/weightedRandomOpponent.py +++ b/grid2op/Opponent/weightedRandomOpponent.py @@ -73,7 +73,7 @@ def init( # Store attackable lines IDs self._lines_ids = [] for l_name in lines_attacked: - l_id = np.where(self.action_space.name_line == l_name) + l_id = np.nonzero(self.action_space.name_line == l_name) if len(l_id) and len(l_id[0]): self._lines_ids.append(l_id[0][0]) else: diff --git a/grid2op/Parameters.py b/grid2op/Parameters.py index 56e523b10..c16d9a939 100644 --- a/grid2op/Parameters.py +++ b/grid2op/Parameters.py @@ -22,9 +22,9 @@ class Parameters: Attributes ---------- NO_OVERFLOW_DISCONNECTION: ``bool`` - If set to ``True`` then the :class:`grid2op.Environment.Environment` will not disconnect powerline above their - thermal - limit. Default is ``False`` + If set to ``True`` then the :class:`grid2op.Environment.Environment` will **NOT** disconnect powerline above their + thermal limit. Default is ``False``, meaning that grid2op will disconnect powerlines above their limits + for too long or for "too much". NB_TIMESTEP_OVERFLOW_ALLOWED: ``int`` Number of timesteps for which a soft overflow is allowed, default 2. This means that a powerline will be diff --git a/grid2op/Plot/EpisodeReplay.py b/grid2op/Plot/EpisodeReplay.py index 77d20d1bd..d2e8ae87a 100644 --- a/grid2op/Plot/EpisodeReplay.py +++ b/grid2op/Plot/EpisodeReplay.py @@ -31,7 +31,8 @@ import imageio_ffmpeg can_save_gif = True -except: +except ImportError as exc_: + warnings.warn(f"Error while importing imageio and imageio_ffmpeg: \n{exc_}") can_save_gif = False diff --git a/grid2op/Plot/PlotPlotly.py b/grid2op/Plot/PlotPlotly.py index 14d5419d0..aae742f32 100644 --- a/grid2op/Plot/PlotPlotly.py +++ b/grid2op/Plot/PlotPlotly.py @@ -143,10 +143,10 @@ def draw_line(pos_sub_or, pos_sub_ex, rho, color_palette, status, line_color="gr Parameters ---------- pos_sub_or: ``tuple`` - Position (x,y) of the origin end of the powerline + Position (x,y) of the origin side of the powerline pos_sub_ex: ``tuple`` - Position (x,y) of the extremity end of the powerline + Position (x,y) of the extremity side of the powerline rho: ``float`` Line capacity usage diff --git a/grid2op/PlotGrid/BasePlot.py b/grid2op/PlotGrid/BasePlot.py index 041cd6d45..707c8d349 100644 --- a/grid2op/PlotGrid/BasePlot.py +++ b/grid2op/PlotGrid/BasePlot.py @@ -1011,10 +1011,10 @@ def plot_info( observation.rho = copy.deepcopy(line_values) try: observation.rho = np.array(observation.rho).astype(dt_float) - except: + except Exception as exc_: raise PlotError( "Impossible to convert the input values (line_values) to floating point" - ) + ) from exc_ # rescaling to have range 0 - 1.0 tmp = observation.rho[np.isfinite(observation.rho)] @@ -1038,10 +1038,10 @@ def plot_info( observation.prod_p = np.array(observation.prod_p).astype( dt_float ) - except: + except Exception as exc_: raise PlotError( "Impossible to convert the input values (gen_values) to floating point" - ) + ) from exc_ # rescaling to have range 0 - 1.0 tmp = observation.prod_p[np.isfinite(observation.prod_p)] diff --git a/grid2op/PlotGrid/PlotMatplot.py b/grid2op/PlotGrid/PlotMatplot.py index 9befd1cc4..ca584dd94 100644 --- a/grid2op/PlotGrid/PlotMatplot.py +++ b/grid2op/PlotGrid/PlotMatplot.py @@ -879,7 +879,7 @@ def draw_powerline( ) self._draw_powerline_bus(pos_ex_x, pos_ex_y, ex_dir_x, ex_dir_y, ex_bus) watt_value = observation.p_or[line_id] - if rho > 0.0 and watt_value != 0.0: + if rho > 0.0 and np.abs(watt_value) >= 1e-7: self._draw_powerline_arrow( pos_or_x, pos_or_y, pos_ex_x, pos_ex_y, color, watt_value ) diff --git a/grid2op/PlotGrid/PlotPlotly.py b/grid2op/PlotGrid/PlotPlotly.py index 52653b0b9..126e40ce9 100644 --- a/grid2op/PlotGrid/PlotPlotly.py +++ b/grid2op/PlotGrid/PlotPlotly.py @@ -144,8 +144,10 @@ def convert_figure_to_numpy_HWC(self, figure): format="png", width=self.width, height=self.height, scale=1 ) return imageio.imread(img_bytes, format="png") - except: - warnings.warn("Plotly need additional dependencies for offline rendering") + except Exception as exc_: + warnings.warn(f"Plotly need additional dependencies for " + f"offline rendering. Error was: " + f"\n{exc_}") return np.full((self.height, self.width, 3), 255, dtype=np.unit8) def _draw_substation_txt(self, name, pos_x, pos_y, text): @@ -564,7 +566,7 @@ def draw_powerline( capacity = observation.rho[line_id] capacity = np.clip(capacity, 0.0, 1.0) color = color_scheme[int(capacity * float(len(color_scheme) - 1))] - if capacity == 0.0: + if np.abs(capacity) <= 1e-7: color = "black" line_style = dict(dash=None if connected else "dash", color=color) line_text = "" @@ -613,7 +615,7 @@ def update_powerline( capacity = min(observation.rho[line_id], 1.0) color_idx = int(capacity * (len(color_scheme) - 1)) color = color_scheme[color_idx] - if capacity == 0.0: + if np.abs(capacity) <= 1e-7: color = "black" if line_value is not None: line_text = pltu.format_value_unit(line_value, line_unit) diff --git a/grid2op/Reward/alarmReward.py b/grid2op/Reward/alarmReward.py index e114a7920..cee617d2c 100644 --- a/grid2op/Reward/alarmReward.py +++ b/grid2op/Reward/alarmReward.py @@ -107,7 +107,7 @@ def _mult_for_zone(self, alarm, disc_lines, env): """compute the multiplicative factor that increases the score if the right zone is predicted""" res = 1.0 # extract the lines that have been disconnected due to cascading failures - lines_disconnected_first = np.where(disc_lines == 0)[0] + lines_disconnected_first = np.nonzero(disc_lines == 0)[0] if ( alarm.sum() > 1 @@ -124,7 +124,7 @@ def _mult_for_zone(self, alarm, disc_lines, env): # now retrieve the id of the zones in which a powerline has been disconnected list_zone_names = list(zones_these_lines) - list_zone_ids = np.where(np.isin(env.alarms_area_names, list_zone_names))[0] + list_zone_ids = np.nonzero(np.isin(env.alarms_area_names, list_zone_names))[0] # and finally, award some extra points if one of the zone, containing one of the powerline disconnected # by protection is in the alarm if alarm[list_zone_ids].any(): diff --git a/grid2op/Reward/alertReward.py b/grid2op/Reward/alertReward.py index 1ab8d4d7c..aac6236d5 100644 --- a/grid2op/Reward/alertReward.py +++ b/grid2op/Reward/alertReward.py @@ -157,7 +157,7 @@ def _update_state(self, env, action): def _compute_score_attack_blackout(self, env, ts_attack_in_order, indexes_to_look): # retrieve the lines that have been attacked in the time window - ts_ind, line_ind = np.where(ts_attack_in_order) + ts_ind, line_ind = np.nonzero(ts_attack_in_order) line_first_attack, first_ind_line_attacked = np.unique(line_ind, return_index=True) ts_first_line_attacked = ts_ind[first_ind_line_attacked] # now retrieve the array starting at the correct place diff --git a/grid2op/Reward/baseReward.py b/grid2op/Reward/baseReward.py index ab54b56a6..51eb5d783 100644 --- a/grid2op/Reward/baseReward.py +++ b/grid2op/Reward/baseReward.py @@ -8,7 +8,10 @@ import logging from abc import ABC, abstractmethod + +import grid2op from grid2op.dtypes import dt_float +from grid2op.Action import BaseAction class BaseReward(ABC): @@ -124,7 +127,7 @@ def is_simulated_env(self, env): from grid2op.Environment._forecast_env import _ForecastEnv return isinstance(env, (_ObsEnv, _ForecastEnv)) - def initialize(self, env): + def initialize(self, env: "grid2op.Environment.BaseEnv") -> None: """ If :attr:`BaseReward.reward_min`, :attr:`BaseReward.reward_max` or other custom attributes require to have a valid :class:`grid2op.Environment.Environment` to be initialized, this should be done in this method. @@ -141,7 +144,7 @@ def initialize(self, env): """ pass - def reset(self, env): + def reset(self, env: "grid2op.Environment.BaseEnv") -> None: """ This method is called each time `env` is reset. @@ -163,7 +166,13 @@ def reset(self, env): pass @abstractmethod - def __call__(self, action, env, has_error, is_done, is_illegal, is_ambiguous): + def __call__(self, + action: BaseAction, + env: "grid2op.Environment.BaseEnv", + has_error: bool, + is_done: bool, + is_illegal: bool, + is_ambiguous: bool) -> float: """ Method called to compute the reward. @@ -228,7 +237,7 @@ def get_range(self): """ return self.reward_min, self.reward_max - def set_range(self, reward_min, reward_max): + def set_range(self, reward_min: float, reward_max: float): """ Setter function for the :attr:`BaseReward.reward_min` and :attr:`BaseReward.reward_max`. @@ -254,9 +263,9 @@ def __iter__(self): yield ("reward_min", float(self.reward_min)) yield ("reward_max", float(self.reward_max)) - def close(self): + def close(self) -> None: """overide this for certain reward that might need specific behaviour""" pass - def is_in_blackout(self, has_error, is_done): + def is_in_blackout(self, has_error, is_done) -> bool: return is_done and has_error diff --git a/grid2op/Reward/n1Reward.py b/grid2op/Reward/n1Reward.py index 9d11561ef..adc1ca43a 100644 --- a/grid2op/Reward/n1Reward.py +++ b/grid2op/Reward/n1Reward.py @@ -13,7 +13,11 @@ class N1Reward(BaseReward): """ - This class implements the "n-1" reward, which returns the maximum flows after a powerline + This class implements a reward that is inspired + by the "n-1" criterion widely used in power system. + + More specifically it returns the maximum flows (on all the powerlines) after a given (as input) a powerline + has been disconnected. Examples -------- @@ -26,8 +30,8 @@ class N1Reward(BaseReward): from grid2op.Reward import N1Reward L_ID = 0 env = grid2op.make("l2rpn_case14_sandbox", - reward_class=N1Reward(l_id=L_ID) - ) + reward_class=N1Reward(l_id=L_ID) + ) obs = env.reset() obs, reward, *_ = env.step(env.action_space()) print(f"reward: {reward:.3f}") diff --git a/grid2op/Rules/BaseRules.py b/grid2op/Rules/BaseRules.py index f6d6b1a44..b822f0f3d 100644 --- a/grid2op/Rules/BaseRules.py +++ b/grid2op/Rules/BaseRules.py @@ -38,7 +38,7 @@ def __call__(self, action, env): As opposed to "ambiguous action", "illegal action" are not illegal per se. They are legal or not on a certain environment. For example, disconnecting a powerline that has been cut off for maintenance is illegal. Saying to action to both disconnect a - powerline and assign it to bus 2 on it's origin end is ambiguous, and not tolerated in Grid2Op. + powerline and assign it to bus 2 on it's origin side is ambiguous, and not tolerated in Grid2Op. Parameters ---------- diff --git a/grid2op/Rules/LookParam.py b/grid2op/Rules/LookParam.py index 13445e612..797f42e5a 100644 --- a/grid2op/Rules/LookParam.py +++ b/grid2op/Rules/LookParam.py @@ -35,13 +35,13 @@ def __call__(self, action, env): aff_lines, aff_subs = action.get_topological_impact(powerline_status) if aff_lines.sum() > env._parameters.MAX_LINE_STATUS_CHANGED: - ids = np.where(aff_lines)[0] + ids = np.nonzero(aff_lines)[0] return False, IllegalAction( "More than {} line status affected by the action: {}" "".format(env.parameters.MAX_LINE_STATUS_CHANGED, ids) ) if aff_subs.sum() > env._parameters.MAX_SUB_CHANGED: - ids = np.where(aff_subs)[0] + ids = np.nonzero(aff_subs)[0] return False, IllegalAction( "More than {} substation affected by the action: {}" "".format(env.parameters.MAX_SUB_CHANGED, ids) diff --git a/grid2op/Rules/PreventDiscoStorageModif.py b/grid2op/Rules/PreventDiscoStorageModif.py index ba52472f1..d75f449d2 100644 --- a/grid2op/Rules/PreventDiscoStorageModif.py +++ b/grid2op/Rules/PreventDiscoStorageModif.py @@ -24,23 +24,23 @@ def __call__(self, action, env): """ See :func:`BaseRules.__call__` for a definition of the parameters of this function. """ - if env.n_storage == 0: + env_cls = type(env) + if env_cls.n_storage == 0: # nothing to do if no storage return True, None # at first iteration, env.current_obs is None... - storage_disco = env.backend.get_topo_vect()[env.storage_pos_topo_vect] < 0 + storage_disco = env.backend.get_topo_vect()[env_cls.storage_pos_topo_vect] < 0 storage_power, storage_set_bus, storage_change_bus = action.get_storage_modif() - power_modif_disco = (np.isfinite(storage_power[storage_disco])) & ( - storage_power[storage_disco] != 0.0 - ) + power_modif_disco = (np.isfinite(storage_power[storage_disco]) & + (np.abs(storage_power[storage_disco]) >= 1e-7)) not_set_status = storage_set_bus[storage_disco] <= 0 not_change_status = ~storage_change_bus[storage_disco] if (power_modif_disco & not_set_status & not_change_status).any(): tmp_ = power_modif_disco & not_set_status & not_change_status return False, IllegalAction( f"Attempt to modify the power produced / absorbed by a storage unit " - f"without reconnecting it (check storage with id {np.where(tmp_)[0]}." + f"without reconnecting it (check storage with id {np.nonzero(tmp_)[0]}." ) return True, None diff --git a/grid2op/Rules/PreventReconnection.py b/grid2op/Rules/PreventReconnection.py index 464c3653e..354a77535 100644 --- a/grid2op/Rules/PreventReconnection.py +++ b/grid2op/Rules/PreventReconnection.py @@ -38,7 +38,7 @@ def __call__(self, action, env): if (env._times_before_line_status_actionable[aff_lines] > 0).any(): # i tried to act on a powerline too shortly after a previous action # or shut down due to an overflow or opponent or hazards or maintenance - ids = np.where((env._times_before_line_status_actionable > 0) & aff_lines)[ + ids = np.nonzero((env._times_before_line_status_actionable > 0) & aff_lines)[ 0 ] return False, IllegalAction( @@ -49,7 +49,7 @@ def __call__(self, action, env): if (env._times_before_topology_actionable[aff_subs] > 0).any(): # I tried to act on a topology too shortly after a previous action - ids = np.where((env._times_before_topology_actionable > 0) & aff_subs)[0] + ids = np.nonzero((env._times_before_topology_actionable > 0) & aff_subs)[0] return False, IllegalAction( "Substation with ids {} have been modified illegally (cooldown of {})".format( ids, env._times_before_topology_actionable[ids] diff --git a/grid2op/Rules/rulesByArea.py b/grid2op/Rules/rulesByArea.py index 66efe22b2..1338cb91f 100644 --- a/grid2op/Rules/rulesByArea.py +++ b/grid2op/Rules/rulesByArea.py @@ -87,7 +87,7 @@ def initialize(self, env): raise Grid2OpException("The number of listed ids of substations in rule initialization does not match the number of " "substations of the chosen environement. Look for missing ids or doublon") else: - self.lines_id_by_area = {key : sorted(list(chain(*[[item for item in np.where(env.line_or_to_subid == subid)[0] + self.lines_id_by_area = {key : sorted(list(chain(*[[item for item in np.nonzero(env.line_or_to_subid == subid)[0] ] for subid in subid_list]))) for key,subid_list in self.substations_id_by_area.items()} @@ -120,13 +120,13 @@ def _lookparam_byarea(self, action, env): aff_lines, aff_subs = action.get_topological_impact(powerline_status) if any([(aff_lines[line_ids]).sum() > env._parameters.MAX_LINE_STATUS_CHANGED for line_ids in self.lines_id_by_area.values()]): - ids = [[k for k in np.where(aff_lines)[0] if k in line_ids] for line_ids in self.lines_id_by_area.values()] + ids = [[k for k in np.nonzero(aff_lines)[0] if k in line_ids] for line_ids in self.lines_id_by_area.values()] return False, IllegalAction( "More than {} line status affected by the action in one area: {}" "".format(env.parameters.MAX_LINE_STATUS_CHANGED, ids) ) if any([(aff_subs[sub_ids]).sum() > env._parameters.MAX_SUB_CHANGED for sub_ids in self.substations_id_by_area.values()]): - ids = [[k for k in np.where(aff_subs)[0] if k in sub_ids] for sub_ids in self.substations_id_by_area.values()] + ids = [[k for k in np.nonzero(aff_subs)[0] if k in sub_ids] for sub_ids in self.substations_id_by_area.values()] return False, IllegalAction( "More than {} substation affected by the action in one area: {}" "".format(env.parameters.MAX_SUB_CHANGED, ids) diff --git a/grid2op/Runner/runner.py b/grid2op/Runner/runner.py index 59747a116..6aa8624f6 100644 --- a/grid2op/Runner/runner.py +++ b/grid2op/Runner/runner.py @@ -243,6 +243,7 @@ def __init__( init_env_path: str, init_grid_path: str, path_chron, # path where chronics of injections are stored + n_busbar=2, name_env="unknown", parameters_path=None, names_chronics_to_backend=None, @@ -346,6 +347,7 @@ def __init__( # TODO documentation on the opponent # TOOD doc for the attention budget """ + self._n_busbar = n_busbar self.with_forecast = with_forecast self.name_env = name_env if not isinstance(envClass, type): @@ -477,7 +479,7 @@ def __init__( # Test if we can copy the agent for parallel runs try: copy.copy(self.agent) - except: + except Exception as exc_: self.__can_copy_agent = False else: raise RuntimeError( @@ -614,6 +616,7 @@ def _new_env(self, chronics_handler, parameters) -> Tuple[BaseEnv, BaseAgent]: with warnings.catch_warnings(): warnings.filterwarnings("ignore") res = self.envClass.init_obj_from_kwargs( + n_busbar=self._n_busbar, other_env_kwargs=self.other_env_kwargs, init_env_path=self.init_env_path, init_grid_path=self.init_grid_path, diff --git a/grid2op/Space/GridObjects.py b/grid2op/Space/GridObjects.py index f14eb3a46..38d0cdc63 100644 --- a/grid2op/Space/GridObjects.py +++ b/grid2op/Space/GridObjects.py @@ -20,13 +20,16 @@ import warnings import copy import numpy as np - +from packaging import version +from typing import Dict, Union, Literal + import grid2op from grid2op.dtypes import dt_int, dt_float, dt_bool from grid2op.Exceptions import * from grid2op.Space.space_utils import extract_from_dict, save_to_dict # TODO tests of these methods and this class in general +DEFAULT_N_BUSBAR_PER_SUB = 2 class GridObjects: @@ -110,18 +113,19 @@ class GridObjects: "local topology" of the substation 4 by looking at :attr:`grid2op.Observation.BaseObservation.topo_vect` [42:47]. iii) retrieve which component of this vector of dimension 5 (remember we assumed substation 4 had 5 elements) - encodes information about the origin end of the line with id `l_id`. This information is given in + encodes information about the origin side of the line with id `l_id`. This information is given in :attr:`GridObjects.line_or_to_sub_pos` [l_id]. This is a number between 0 and 4, say it's 3. 3 being the index of the object in the substation) - method 2 (not recommended): all of the above is stored (for the same powerline) in the :attr:`GridObjects.line_or_pos_topo_vect` [l_id]. In the example above, we will have: - :attr:`GridObjects.line_or_pos_topo_vect` [l_id] = 45 (=42+3: + :attr:`GridObjects.line_or_pos_topo_vect` [l_id] = 45 (=42+3): 42 being the index on which the substation started and 3 being the index of the object in the substation) - method 3 (recommended): use any of the function that computes it for you: :func:`grid2op.Observation.BaseObservation.state_of` is such an interesting method. The two previous methods "method 1" and "method 2" were presented as a way to give detailed and "concrete" example on how the modeling of the powergrid work. + - method 4 (recommended): use the :func:`GridObjects.topo_vect_element` For a given powergrid, this object should be initialized once in the :class:`grid2op.Backend.Backend` when the first call to :func:`grid2op.Backend.Backend.load_grid` is performed. In particular the following attributes @@ -153,7 +157,7 @@ class GridObjects: - :attr:`GridObjects.line_ex_to_sub_pos` - :attr:`GridObjects.storage_to_sub_pos` - A call to the function :func:`GridObjects._compute_pos_big_topo_cls` allow to compute the \*_pos_topo_vect attributes + A call to the function :func:`GridObjects._compute_pos_big_topo_cls` allow to compute the \\*_pos_topo_vect attributes (for example :attr:`GridObjects.line_ex_pos_topo_vect`) can be computed from the above data: - :attr:`GridObjects.load_pos_topo_vect` @@ -187,6 +191,12 @@ class GridObjects: Attributes ---------- + n_busbar_per_sub: :class:`int` + number of independant busbars for all substations [*class attribute*]. It's 2 by default + or if the implementation of the backend does not support this feature. + + .. versionadded:: 1.10.0 + n_line: :class:`int` number of powerlines in the powergrid [*class attribute*] @@ -253,7 +263,7 @@ class GridObjects: :attr:`GridObjects.load_to_sub_pos` [l] is the index of the load *l* in the vector :attr:`grid2op.BaseObservation.BaseObservation.topo_vect` . This means that, if - "`topo_vect` [ :attr:`GridObjects.load_pos_topo_vect` \[l\] ]=2" + "`topo_vect` [ :attr:`GridObjects.load_pos_topo_vect` \\[l\\] ]=2" then load of id *l* is connected to the second bus of the substation. [*class attribute*] gen_pos_topo_vect: :class:`numpy.ndarray`, dtype:int @@ -487,6 +497,7 @@ class GridObjects: name_sub = None name_storage = None + n_busbar_per_sub = DEFAULT_N_BUSBAR_PER_SUB n_gen = -1 n_load = -1 n_line = -1 @@ -618,6 +629,10 @@ def __init__(self): """nothing to do when an object of this class is created, the information is held by the class attributes""" pass + @classmethod + def set_n_busbar_per_sub(cls, n_busbar_per_sub): + cls.n_busbar_per_sub = n_busbar_per_sub + @classmethod def tell_dim_alarm(cls, dim_alarms): if cls.dim_alarms != 0: @@ -651,6 +666,7 @@ def tell_dim_alert(cls, dim_alerts): @classmethod def _clear_class_attribute(cls): cls.shunts_data_available = False + cls.n_busbar_per_sub = DEFAULT_N_BUSBAR_PER_SUB # for redispatching / unit commitment cls._li_attr_disp = [ @@ -1842,6 +1858,12 @@ def assert_grid_correct_cls(cls): # TODO refactor this method with the `_check***` methods. # TODO refactor the `_check***` to use the same "base functions" that would be coded only once. + 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: + raise EnvError(f"`n_busbar_per_sub` should be >= 1 found {cls.n_busbar_per_sub}") + if cls.n_gen <= 0: raise EnvError( "n_gen is negative. Powergrid is invalid: there are no generator" @@ -2014,7 +2036,7 @@ def assert_grid_correct_cls(cls): if not np.all(obj_per_sub == cls.sub_info): raise IncorrectNumberOfElements( - f"for substation(s): {np.where(obj_per_sub != cls.sub_info)[0]}" + f"for substation(s): {np.nonzero(obj_per_sub != cls.sub_info)[0]}" ) # test right number of element in substations @@ -2033,12 +2055,12 @@ def assert_grid_correct_cls(cls): zip(cls.line_or_to_subid, cls.line_or_to_sub_pos) ): if sub_pos >= cls.sub_info[sub_id]: - raise IncorrectPositionOfLines("for line {} at origin end".format(i)) + raise IncorrectPositionOfLines("for line {} at origin side".format(i)) for i, (sub_id, sub_pos) in enumerate( zip(cls.line_ex_to_subid, cls.line_ex_to_sub_pos) ): if sub_pos >= cls.sub_info[sub_id]: - raise IncorrectPositionOfLines("for line {} at extremity end".format(i)) + raise IncorrectPositionOfLines("for line {} at extremity side".format(i)) for i, (sub_id, sub_pos) in enumerate( zip(cls.storage_to_subid, cls.storage_to_sub_pos) ): @@ -2315,57 +2337,57 @@ def _check_validity_storage_data(cls): ) if (cls.storage_Emax < cls.storage_Emin).any(): - tmp = np.where(cls.storage_Emax < cls.storage_Emin)[0] + tmp = np.nonzero(cls.storage_Emax < cls.storage_Emin)[0] raise BackendError( f"storage_Emax < storage_Emin for storage units with ids: {tmp}" ) if (cls.storage_Emax < 0.0).any(): - tmp = np.where(cls.storage_Emax < 0.0)[0] + tmp = np.nonzero(cls.storage_Emax < 0.0)[0] raise BackendError( f"self.storage_Emax < 0. for storage units with ids: {tmp}" ) if (cls.storage_Emin < 0.0).any(): - tmp = np.where(cls.storage_Emin < 0.0)[0] + tmp = np.nonzero(cls.storage_Emin < 0.0)[0] raise BackendError( f"self.storage_Emin < 0. for storage units with ids: {tmp}" ) if (cls.storage_max_p_prod < 0.0).any(): - tmp = np.where(cls.storage_max_p_prod < 0.0)[0] + tmp = np.nonzero(cls.storage_max_p_prod < 0.0)[0] raise BackendError( f"self.storage_max_p_prod < 0. for storage units with ids: {tmp}" ) if (cls.storage_max_p_absorb < 0.0).any(): - tmp = np.where(cls.storage_max_p_absorb < 0.0)[0] + tmp = np.nonzero(cls.storage_max_p_absorb < 0.0)[0] raise BackendError( f"self.storage_max_p_absorb < 0. for storage units with ids: {tmp}" ) if (cls.storage_loss < 0.0).any(): - tmp = np.where(cls.storage_loss < 0.0)[0] + tmp = np.nonzero(cls.storage_loss < 0.0)[0] raise BackendError( f"self.storage_loss < 0. for storage units with ids: {tmp}" ) if (cls.storage_discharging_efficiency <= 0.0).any(): - tmp = np.where(cls.storage_discharging_efficiency <= 0.0)[0] + tmp = np.nonzero(cls.storage_discharging_efficiency <= 0.0)[0] raise BackendError( f"self.storage_discharging_efficiency <= 0. for storage units with ids: {tmp}" ) if (cls.storage_discharging_efficiency > 1.0).any(): - tmp = np.where(cls.storage_discharging_efficiency > 1.0)[0] + tmp = np.nonzero(cls.storage_discharging_efficiency > 1.0)[0] raise BackendError( f"self.storage_discharging_efficiency > 1. for storage units with ids: {tmp}" ) if (cls.storage_charging_efficiency < 0.0).any(): - tmp = np.where(cls.storage_charging_efficiency < 0.0)[0] + tmp = np.nonzero(cls.storage_charging_efficiency < 0.0)[0] raise BackendError( f"self.storage_charging_efficiency < 0. for storage units with ids: {tmp}" ) if (cls.storage_charging_efficiency > 1.0).any(): - tmp = np.where(cls.storage_charging_efficiency > 1.0)[0] + tmp = np.nonzero(cls.storage_charging_efficiency > 1.0)[0] raise BackendError( f"self.storage_charging_efficiency > 1. for storage units with ids: {tmp}" ) if (cls.storage_loss > cls.storage_max_p_absorb).any(): - tmp = np.where(cls.storage_loss > cls.storage_max_p_absorb)[0] + tmp = np.nonzero(cls.storage_loss > cls.storage_max_p_absorb)[0] raise BackendError( f"Some storage units are such that their loss (self.storage_loss) is higher " f"than the maximum power at which they can be charged (self.storage_max_p_absorb). " @@ -2714,6 +2736,11 @@ def init_grid(cls, gridobj, force=False, extra_name=None, force_module=None): # with shunt and without shunt, then # there might be issues name_res += "_noshunt" + + if gridobj.n_busbar_per_sub != DEFAULT_N_BUSBAR_PER_SUB: + # to be able to load same environment with + # different `n_busbar_per_sub` + name_res += f"_{gridobj.n_busbar_per_sub}" if name_res in globals(): if not force: @@ -2737,8 +2764,7 @@ def init_grid(cls, gridobj, force=False, extra_name=None, force_module=None): res_cls._compute_pos_big_topo_cls() res_cls.process_shunt_satic_data() - if res_cls.glop_version != grid2op.__version__: - res_cls.process_grid2op_compat() + res_cls.process_grid2op_compat() if force_module is not None: res_cls.__module__ = force_module # hack because otherwise it says "abc" which is not the case @@ -2749,23 +2775,45 @@ def init_grid(cls, gridobj, force=False, extra_name=None, force_module=None): del res_cls return globals()[name_res] + @classmethod + def _get_grid2op_version_as_version_obj(cls): + if cls.glop_version == cls.BEFORE_COMPAT_VERSION: + glop_ver = version.parse("0.0.0") + else: + glop_ver = version.parse(cls.glop_version) + return glop_ver + @classmethod def process_grid2op_compat(cls): """ - This function can be overloaded. + INTERNAL + .. warning:: /!\\\\ Internal, do not use unless you know what you are doing /!\\\\ + This is done at the creation of the environment. Use of this class outside of this particular + use is really dangerous and will lead to undefined behaviours. **Do not use this function**. + This is called when the class is initialized, with `init_grid` to broadcast grid2op compatibility feature. + + This function can be overloaded, but in this case it's best to call this original method too. + """ - if cls.glop_version < "1.6.0": + glop_ver = cls._get_grid2op_version_as_version_obj() + + if glop_ver < version.parse("1.6.0"): # this feature did not exist before. cls.dim_alarms = 0 cls.assistant_warning_type = None - if cls.glop_version < "1.9.1": + if glop_ver < version.parse("1.9.1"): # this feature did not exists before cls.dim_alerts = 0 cls.alertable_line_names = [] cls.alertable_line_ids = [] + + if glop_ver < version.parse("1.10.0.dev0"): + # this feature did not exists before + # I need to set it to the default if set elsewhere + cls.n_busbar_per_sub = DEFAULT_N_BUSBAR_PER_SUB @classmethod def get_obj_connect_to(cls, _sentinel=None, substation_id=None): @@ -2812,9 +2860,9 @@ def get_obj_connect_to(cls, _sentinel=None, substation_id=None): sub_id, env.name_load[dict_["loads_id"]])) print("The names of the generators connected to substation {} are: {}".format( sub_id, env.name_gen[dict_["generators_id"]])) - print("The powerline whose origin end is connected to substation {} are: {}".format( + print("The powerline whose origin side is connected to substation {} are: {}".format( sub_id, env.name_line[dict_["lines_or_id"]])) - print("The powerline whose extremity end is connected to substation {} are: {}".format( + print("The powerline whose extremity side is connected to substation {} are: {}".format( sub_id, env.name_line[dict_["lines_ex_id"]])) print("The storage units connected to substation {} are: {}".format( sub_id, env.name_line[dict_["storages_id"]])) @@ -2836,15 +2884,55 @@ def get_obj_connect_to(cls, _sentinel=None, substation_id=None): "".format(substation_id) ) res = { - "loads_id": np.where(cls.load_to_subid == substation_id)[0], - "generators_id": np.where(cls.gen_to_subid == substation_id)[0], - "lines_or_id": np.where(cls.line_or_to_subid == substation_id)[0], - "lines_ex_id": np.where(cls.line_ex_to_subid == substation_id)[0], - "storages_id": np.where(cls.storage_to_subid == substation_id)[0], + "loads_id": np.nonzero(cls.load_to_subid == substation_id)[0], + "generators_id": np.nonzero(cls.gen_to_subid == substation_id)[0], + "lines_or_id": np.nonzero(cls.line_or_to_subid == substation_id)[0], + "lines_ex_id": np.nonzero(cls.line_ex_to_subid == substation_id)[0], + "storages_id": np.nonzero(cls.storage_to_subid == substation_id)[0], "nb_elements": cls.sub_info[substation_id], } return res + @classmethod + def get_powerline_id(cls, sub_id: int) -> np.ndarray: + """ + Return the id of all powerlines connected to the substation `sub_id` + either "or" side or "ex" side + + Parameters + ----------- + sub_id: `int` + The id of the substation concerned + + Returns + ------- + res: np.ndarray, int + The id of all powerlines connected to this substation (either or side or ex side) + + Examples + -------- + + To get the id of all powerlines connected to substation with id 1, + you can do: + + .. code-block:: python + + import numpy as np + import grid2op + env = grid2op.make("l2rpn_case14_sandbox") + + all_lines_conn_to_sub_id_1 = type(env).get_powerline_id(1) + + """ + powerlines_or_id = cls.line_or_to_sub_pos[ + cls.line_or_to_subid == sub_id + ] + powerlines_ex_id = cls.line_ex_to_sub_pos[ + cls.line_ex_to_subid == sub_id + ] + powerlines_id = np.concatenate((powerlines_or_id, powerlines_ex_id)) + return powerlines_id + @classmethod def get_obj_substations(cls, _sentinel=None, substation_id=None): """ @@ -2870,10 +2958,10 @@ def get_obj_substations(cls, _sentinel=None, substation_id=None): 1. column 0: the id of the substation 2. column 1: -1 if this object is not a load, or `LOAD_ID` if this object is a load (see example) 3. column 2: -1 if this object is not a generator, or `GEN_ID` if this object is a generator (see example) - 4. column 3: -1 if this object is not the origin end of a line, or `LOR_ID` if this object is the - origin end of a powerline(see example) - 5. column 4: -1 if this object is not a extremity end, or `LEX_ID` if this object is the extremity - end of a powerline + 4. column 3: -1 if this object is not the origin side of a line, or `LOR_ID` if this object is the + origin side of a powerline(see example) + 5. column 4: -1 if this object is not a extremity side, or `LEX_ID` if this object is the extremity + side of a powerline 6. column 5: -1 if this object is not a storage unit, or `STO_ID` if this object is one Examples @@ -2896,14 +2984,14 @@ def get_obj_substations(cls, _sentinel=None, substation_id=None): # we can also get that: # 1. this is not a load (-1 at position 1 - so 2nd component) # 2. this is not a generator (-1 at position 2 - so 3rd component) - # 3. this is not the origin end of a powerline (-1 at position 3) - # 4. this is the extremity end of powerline 0 (there is a 0 at position 4) + # 3. this is not the origin side of a powerline (-1 at position 3) + # 4. this is the extremity side of powerline 0 (there is a 0 at position 4) # 5. this is not a storage unit (-1 at position 5 - so last component) # likewise, the second element connected at this substation is: mat[1,:] # array([ 1, -1, -1, 2, -1, -1], dtype=int32) - # it represents the origin end of powerline 2 + # it represents the origin side of powerline 2 # the 5th element connected at this substation is: mat[4,:] @@ -2956,7 +3044,8 @@ def get_obj_substations(cls, _sentinel=None, substation_id=None): ] return res - def get_lines_id(self, _sentinel=None, from_=None, to_=None): + @classmethod + def get_lines_id(cls, _sentinel=None, from_=None, to_=None): """ Returns the list of all the powerlines id in the backend going from `from_` to `to_` @@ -2966,10 +3055,10 @@ def get_lines_id(self, _sentinel=None, from_=None, to_=None): Internal, do not use from_: ``int`` - Id the substation to which the origin end of the powerline to look for should be connected to + Id the substation to which the origin side of the powerline to look for should be connected to to_: ``int`` - Id the substation to which the extremity end of the powerline to look for should be connected to + Id the substation to which the extremity side of the powerline to look for should be connected to Returns ------- @@ -3008,7 +3097,7 @@ def get_lines_id(self, _sentinel=None, from_=None, to_=None): ) for i, (ori, ext) in enumerate( - zip(self.line_or_to_subid, self.line_ex_to_subid) + zip(cls.line_or_to_subid, cls.line_ex_to_subid) ): if ori == from_ and ext == to_: res.append(i) @@ -3021,7 +3110,8 @@ def get_lines_id(self, _sentinel=None, from_=None, to_=None): return res - def get_generators_id(self, sub_id): + @classmethod + def get_generators_id(cls, sub_id): """ Returns the list of all generators id in the backend connected to the substation sub_id @@ -3061,7 +3151,7 @@ def get_generators_id(self, sub_id): 'Please modify "sub_id" parameter' ) - for i, s_id_gen in enumerate(self.gen_to_subid): + for i, s_id_gen in enumerate(cls.gen_to_subid): if s_id_gen == sub_id: res.append(i) @@ -3073,7 +3163,8 @@ def get_generators_id(self, sub_id): return res - def get_loads_id(self, sub_id): + @classmethod + def get_loads_id(cls, sub_id): """ Returns the list of all loads id in the backend connected to the substation sub_id @@ -3112,7 +3203,7 @@ def get_loads_id(self, sub_id): 'Please modify "sub_id" parameter' ) - for i, s_id_gen in enumerate(self.load_to_subid): + for i, s_id_gen in enumerate(cls.load_to_subid): if s_id_gen == sub_id: res.append(i) @@ -3125,7 +3216,8 @@ def get_loads_id(self, sub_id): return res - def get_storages_id(self, sub_id): + @classmethod + def get_storages_id(cls, sub_id): """ Returns the list of all storages element (battery or damp) id in the grid connected to the substation sub_id @@ -3164,24 +3256,129 @@ def get_storages_id(self, sub_id): 'Please modify "sub_id" parameter' ) - for i, s_id_gen in enumerate(self.storage_to_subid): + for i, s_id_gen in enumerate(cls.storage_to_subid): if s_id_gen == sub_id: res.append(i) if not res: # res is empty here raise BackendError( - "GridObjects.bd: impossible to find a storage unit connected at substation {}".format( + "GridObjects.get_storages_id: impossible to find a storage unit connected at substation {}".format( sub_id ) ) return res + @classmethod + def topo_vect_element(cls, topo_vect_id: int) -> Dict[Literal["load_id", "gen_id", "line_id", "storage_id", "line_or_id", "line_ex_id", "sub_id"], + Union[int, Dict[Literal["or", "ex"], int]]]: + """ + This function aims to be the "opposite" of the + `cls.xxx_pos_topo_vect` (**eg** `cls.load_pos_topo_vect`) + + You give it an id in the topo_vect (*eg* 10) and it gives you + information about which element it is. More precisely, if + `type(env).topo_vect[topo_vect_id]` is: + + - a **load** then it will return `{'load_id': load_id}`, with `load_id` + being such that `type(env).load_pos_topo_vect[load_id] == topo_vect_id` + - a **generator** then it will return `{'gen_id': gen_id}`, with `gen_id` + being such that `type(env).gen_pos_topo_vect[gen_id] == topo_vect_id` + - a **storage** then it will return `{'storage_id': storage_id}`, with `storage_id` + being such that `type(env).storage_pos_topo_vect[storage_id] == topo_vect_id` + - a **line** (origin side) then it will return `{'line_id': {'or': line_id}, 'line_or_id': line_id}`, + with `line_id` + being such that `type(env).line_or_pos_topo_vect[line_id] == topo_vect_id` + - a **line** (ext side) then it will return `{'line_id': {'ex': line_id}, 'line_ex_id': line_id}`, + with `line_id` + being such that `type(env).line_or_pos_topo_vect[line_id] == topo_vect_id` + + .. seealso:: + The attributes :attr:`GridObjects.load_pos_topo_vect`, :attr:`GridObjects.gen_pos_topo_vect`, + :attr:`GridObjects.storage_pos_topo_vect`, :attr:`GridObjects.line_or_pos_topo_vect` and + :attr:`GridObjects.line_ex_pos_topo_vect` to do the opposite. + + And you can also have a look at :attr:`GridObjects.grid_objects_types` + + Parameters + ---------- + topo_vect_id: ``int`` + The element of the topo vect to which you want more information. + + Returns + ------- + res: ``dict`` + See details in the description + + Examples + -------- + It can be used like: + + .. code-block:: python + + import numpy as np + import grid2op + env = grid2op.make("l2rpn_case14_sandbox") + + env_cls = type(env) # or `type(act)` or` type(obs)` etc. or even `env.topo_vect_element(...)` or `obs.topo_vect_element(...)` + for load_id, pos_topo_vect in enumerate(env_cls.load_pos_topo_vect): + res = env_cls.topo_vect_element(pos_topo_vect) + assert "load_id" in res + assert res["load_id"] == load_id + + for gen_id, pos_topo_vect in enumerate(env_cls.gen_pos_topo_vect): + res = env_cls.topo_vect_element(pos_topo_vect) + assert "gen_id" in res + assert res["gen_id"] == gen_id + + for sto_id, pos_topo_vect in enumerate(env_cls.storage_pos_topo_vect): + res = env_cls.topo_vect_element(pos_topo_vect) + assert "storage_id" in res + assert res["storage_id"] == sto_id + + for line_id, pos_topo_vect in enumerate(env_cls.line_or_pos_topo_vect): + res = env_cls.topo_vect_element(pos_topo_vect) + assert "line_id" in res + assert res["line_id"] == {"or": line_id} + assert "line_or_id" in res + assert res["line_or_id"] == line_id + + for line_id, pos_topo_vect in enumerate(env_cls.line_ex_pos_topo_vect): + res = env_cls.topo_vect_element(pos_topo_vect) + assert "line_id" in res + assert res["line_id"] == {"ex": line_id} + assert "line_ex_id" in res + assert res["line_ex_id"] == line_id + + """ + elt = cls.grid_objects_types[topo_vect_id] + res = {"sub_id": int(elt[cls.SUB_COL])} + if elt[cls.LOA_COL] != -1: + res["load_id"] = int(elt[cls.LOA_COL]) + return res + if elt[cls.GEN_COL] != -1: + res["gen_id"] = int(elt[cls.GEN_COL]) + return res + if elt[cls.STORAGE_COL] != -1: + res["storage_id"] = int(elt[cls.STORAGE_COL]) + return res + if elt[cls.LOR_COL] != -1: + res["line_or_id"] = int(elt[cls.LOR_COL]) + res["line_id"] = {"or": int(elt[cls.LOR_COL])} + return res + if elt[cls.LEX_COL] != -1: + res["line_ex_id"] = int(elt[cls.LEX_COL]) + res["line_id"] = {"ex": int(elt[cls.LEX_COL])} + return res + raise Grid2OpException(f"Unknown element at position {topo_vect_id}") + @staticmethod def _make_cls_dict(cls, res, as_list=True, copy_=True): """NB: `cls` can be here a class or an object of a class...""" save_to_dict(res, cls, "glop_version", str, copy_) res["_PATH_ENV"] = cls._PATH_ENV # i do that manually for more control + save_to_dict(res, cls, "n_busbar_per_sub", str, copy_) + save_to_dict( res, cls, @@ -3510,6 +3707,8 @@ def _make_cls_dict_extended(cls, res, as_list=True, copy_=True): res[ "redispatching_unit_commitment_availble" ] = cls.redispatching_unit_commitment_availble + # n_busbar_per_sub + res["n_busbar_per_sub"] = cls.n_busbar_per_sub @classmethod def cls_to_dict(cls): @@ -3562,7 +3761,7 @@ class res(GridObjects): cls = res if "glop_version" in dict_: - cls.glop_version = dict_["glop_version"] + cls.glop_version = str(dict_["glop_version"]) else: cls.glop_version = cls.BEFORE_COMPAT_VERSION @@ -3570,6 +3769,12 @@ class res(GridObjects): cls._PATH_ENV = str(dict_["_PATH_ENV"]) else: cls._PATH_ENV = None + + if 'n_busbar_per_sub' in dict_: + cls.n_busbar_per_sub = int(dict_["n_busbar_per_sub"]) + else: + # compat version: was not set + cls.n_busbar_per_sub = DEFAULT_N_BUSBAR_PER_SUB cls.name_gen = extract_from_dict( dict_, "name_gen", lambda x: np.array(x).astype(str) @@ -3953,7 +4158,7 @@ def __reduce__(self): ) @classmethod - def local_bus_to_global(cls, local_bus, to_sub_id): + def local_bus_to_global(cls, local_bus: np.ndarray, to_sub_id: np.ndarray) -> np.ndarray: """This function translate "local bus" whose id are in a substation, to "global bus id" whose id are consistent for the whole grid. @@ -3962,24 +4167,30 @@ def local_bus_to_global(cls, local_bus, to_sub_id): global id 41 or 40 or 39 or etc. .. note:: - Typically, "local bus" are numbered 1 or 2. They represent the id of the busbar to which the element + Typically, "local bus" are numbered 1, 2, ... cls.n_busbar_per_sub. They represent the id of the busbar to which the element is connected IN its substation. On the other hand, the "global bus" are numberd, 0, 1, 2, 3, ..., 2 * self.n_sub. They represent some kind of "universal" labelling of the busbars of all the grid. For example, substation 0 might have busbar `0` and `self.n_sub`, substation 1 have busbar `1` and `self.n_sub + 1` etc. - [on_bus_1] + Local and global bus id represents the same thing. The difference comes down to convention. + + ..warning:: + In order to be as fast as possible, these functions do not check for "out of bound" or + "impossible" configuration. + + They assume that the input data are consistent with the grid. """ global_bus = (1 * local_bus).astype(dt_int) # make a copy - on_bus_1 = global_bus == 1 - on_bus_2 = global_bus == 2 - global_bus[on_bus_1] = to_sub_id[on_bus_1] - global_bus[on_bus_2] = to_sub_id[on_bus_2] + cls.n_sub + global_bus[local_bus < 0] = -1 + for i in range(cls.n_busbar_per_sub): + on_bus_i = local_bus == i + 1 + global_bus[on_bus_i] = to_sub_id[on_bus_i] + i * cls.n_sub return global_bus @classmethod - def local_bus_to_global_int(cls, local_bus, to_sub_id): + def local_bus_to_global_int(cls, local_bus : int, to_sub_id : int) -> int: """This function translate "local bus" whose id are in a substation, to "global bus id" whose id are consistent for the whole grid. @@ -3988,26 +4199,30 @@ def local_bus_to_global_int(cls, local_bus, to_sub_id): global id 41 or 40 or 39 or etc. .. note:: - Typically, "local bus" are numbered 1 or 2. They represent the id of the busbar to which the element + Typically, "local bus" are numbered 1, 2, ... cls.n_busbar_per_sub. They represent the id of the busbar to which the element is connected IN its substation. - On the other hand, the "global bus" are numberd, 0, 1, 2, 3, ..., 2 * self.n_sub. They represent some kind of + On the other hand, the "global bus" are numberd, 0, 1, 2, 3, ..., cls.n_busbar_per_sub * self.n_sub. They represent some kind of "universal" labelling of the busbars of all the grid. For example, substation 0 might have busbar `0` and `self.n_sub`, substation 1 have busbar `1` and `self.n_sub + 1` etc. Local and global bus id represents the same thing. The difference comes down to convention. .. note:: - This is the "non vectorized" version that applies only on integers. + This is the "non vectorized" version that applies only on integers. + + ..warning:: + In order to be as fast as possible, these functions do not check for "out of bound" or + "impossible" configuration. + + They assume that the input data are consistent with the grid. """ - if local_bus == 1: - return to_sub_id - elif local_bus == 2: - return to_sub_id + cls.n_sub - return -1 + if local_bus == -1: + return -1 + return to_sub_id + (int(local_bus) - 1) * cls.n_sub @classmethod - def global_bus_to_local(cls, global_bus, to_sub_id): + def global_bus_to_local(cls, global_bus: np.ndarray, to_sub_id: np.ndarray) -> np.ndarray: """This function translate "local bus" whose id are in a substation, to "global bus id" whose id are consistent for the whole grid. @@ -4016,23 +4231,29 @@ def global_bus_to_local(cls, global_bus, to_sub_id): global id 41 or 40 or 39 or etc. .. note:: - Typically, "local bus" are numbered 1 or 2. They represent the id of the busbar to which the element + Typically, "local bus" are numbered 1, 2, ... cls.n_busbar_per_sub. They represent the id of the busbar to which the element is connected IN its substation. - On the other hand, the "global bus" are numberd, 0, 1, 2, 3, ..., 2 * self.n_sub. They represent some kind of + On the other hand, the "global bus" are numberd, 0, 1, 2, 3, ..., cls.n_busbar_per_sub * self.n_sub. They represent some kind of "universal" labelling of the busbars of all the grid. For example, substation 0 might have busbar `0` and `self.n_sub`, substation 1 have busbar `1` and `self.n_sub + 1` etc. Local and global bus id represents the same thing. The difference comes down to convention. + + ..warning:: + In order to be as fast as possible, these functions do not check for "out of bound" or + "impossible" configuration. + + They assume that the input data are consistent with the grid. """ res = (1 * global_bus).astype(dt_int) # make a copy - res[global_bus < cls.n_sub] = 1 - res[global_bus >= cls.n_sub] = 2 + for i in range(cls.n_busbar_per_sub): + res[(i * cls.n_sub <= global_bus) & (global_bus < (i+1) * cls.n_sub)] = i + 1 res[global_bus == -1] = -1 return res @classmethod - def global_bus_to_local_int(cls, global_bus, to_sub_id): + def global_bus_to_local_int(cls, global_bus: int, to_sub_id: int) -> int: """This function translate "local bus" whose id are in a substation, to "global bus id" whose id are consistent for the whole grid. @@ -4041,22 +4262,27 @@ def global_bus_to_local_int(cls, global_bus, to_sub_id): global id 41 or 40 or 39 or etc. .. note:: - Typically, "local bus" are numbered 1 or 2. They represent the id of the busbar to which the element + Typically, "local bus" are numbered 1, 2, ... cls.n_busbar_per_sub. They represent the id of the busbar to which the element is connected IN its substation. - On the other hand, the "global bus" are numberd, 0, 1, 2, 3, ..., 2 * self.n_sub. They represent some kind of + On the other hand, the "global bus" are numberd, 0, 1, 2, 3, ..., cls.n_busbar_per_sub * self.n_sub. They represent some kind of "universal" labelling of the busbars of all the grid. For example, substation 0 might have busbar `0` and `self.n_sub`, substation 1 have busbar `1` and `self.n_sub + 1` etc. - Local and global bus id represents the same thing. The difference comes down to convention. + Local and global bus id represents the same thing. The difference comes down to convention. + + ..warning:: + In order to be as fast as possible, these functions do not check for "out of bound" or + "impossible" configuration. + + They assume that the input data are consistent with the grid. """ if global_bus == -1: return -1 - if global_bus < cls.n_sub: - return 1 - if global_bus >= cls.n_sub: - return 2 - return -1 + for i in range(cls.n_busbar_per_sub): + if global_bus < (i+1) * cls.n_sub: + return i+1 + raise EnvError(f"This environment can have only {cls.n_busbar_per_sub} independant busbars per substation.") @staticmethod def _format_int_vect_to_cls_str(int_vect): @@ -4250,7 +4476,7 @@ def format_el(values): tmp_tmp_ = ",".join([f"{el}" for el in cls.alertable_line_ids]) tmp_ = f"[{tmp_tmp_}]" alertable_line_ids_str = '[]' if cls.dim_alerts == 0 else tmp_ - res = f"""# Copyright (c) 2019-2023, RTE (https://www.rte-france.com) + res = f"""# Copyright (c) 2019-2024, RTE (https://www.rte-france.com) # See AUTHORS.txt # This Source Code Form is subject to the terms of the Mozilla Public License, version 2.0. # If a copy of the Mozilla Public License, version 2.0 was not distributed with this file, @@ -4293,6 +4519,7 @@ class {cls.__name__}({cls._INIT_GRID_CLS.__name__}): name_sub = np.array([{name_sub_str}]) name_storage = np.array([{name_storage_str}]) + n_busbar_per_sub = {cls.n_busbar_per_sub} n_gen = {cls.n_gen} n_load = {cls.n_load} n_line = {cls.n_line} diff --git a/grid2op/Space/__init__.py b/grid2op/Space/__init__.py index 635b30e44..69387627d 100644 --- a/grid2op/Space/__init__.py +++ b/grid2op/Space/__init__.py @@ -1,5 +1,5 @@ -__all__ = ["RandomObject", "SerializableSpace", "GridObjects"] +__all__ = ["RandomObject", "SerializableSpace", "GridObjects", "DEFAULT_N_BUSBAR_PER_SUB"] from grid2op.Space.RandomObject import RandomObject from grid2op.Space.SerializableSpace import SerializableSpace -from grid2op.Space.GridObjects import GridObjects +from grid2op.Space.GridObjects import GridObjects, DEFAULT_N_BUSBAR_PER_SUB diff --git a/grid2op/__init__.py b/grid2op/__init__.py index 2979858ec..f8ec72c3a 100644 --- a/grid2op/__init__.py +++ b/grid2op/__init__.py @@ -11,7 +11,7 @@ Grid2Op """ -__version__ = '1.9.8' +__version__ = '1.10.0.dev1' __all__ = [ "Action", diff --git a/grid2op/gym_compat/box_gym_actspace.py b/grid2op/gym_compat/box_gym_actspace.py index aed07d132..0516fcf70 100644 --- a/grid2op/gym_compat/box_gym_actspace.py +++ b/grid2op/gym_compat/box_gym_actspace.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 Tuple +from typing import Literal, Dict, Tuple, Any, Optional import copy import warnings import numpy as np @@ -28,6 +28,18 @@ GYM_AVAILABLE, GYMNASIUM_AVAILABLE) +POSSIBLE_KEYS = Literal["redispatch", + "curtail", + "curtail_mw", + "set_storage", + "set_bus", + "change_bus", + "set_line_status", + "change_line_status", + "raise_alert", + "raise_alarm" + ] + class __AuxBoxGymActSpace: """ @@ -85,9 +97,9 @@ class __AuxBoxGymActSpace: .. code-block:: python gym_env.action_space = BoxGymActSpace(env.action_space, - attr_to_keep=['redispatch', "curtail"], - multiply={"redispatch": env.gen_max_ramp_up}, - add={"redispatch": 0.5 * env.gen_max_ramp_up}) + attr_to_keep=['redispatch', "curtail"], + multiply={"redispatch": env.gen_max_ramp_up}, + add={"redispatch": 0.5 * env.gen_max_ramp_up}) In the above example, the resulting "redispatch" part of the vector will be given by the following formula: `grid2op_act = gym_act * multiply + add` @@ -190,11 +202,21 @@ def from_gym(self, gym_action): def __init__( self, - grid2op_action_space, - attr_to_keep=ALL_ATTR_CONT, - add=None, - multiply=None, - functs=None, + grid2op_action_space: ActionSpace, + attr_to_keep: Optional[Tuple[Literal["set_line_status"], + Literal["change_line_status"], + Literal["set_bus"], + Literal["change_bus"], + Literal["redispatch"], + Literal["set_storage"], + Literal["curtail"], + Literal["curtail_mw"], + Literal["raise_alarm"], + Literal["raise_alert"], + ]]=ALL_ATTR_CONT, + add: Optional[Dict[str, Any]]=None, + multiply: Optional[Dict[str, Any]]=None, + functs: Optional[Dict[str, Any]]=None, ): if not isinstance(grid2op_action_space, ActionSpace): raise RuntimeError( @@ -225,45 +247,45 @@ def __init__( self._attr_to_keep = sorted(attr_to_keep) - act_sp = grid2op_action_space + act_sp_cls = type(grid2op_action_space) self._act_space = copy.deepcopy(grid2op_action_space) - low_gen = -1.0 * act_sp.gen_max_ramp_down[act_sp.gen_redispatchable] - high_gen = 1.0 * act_sp.gen_max_ramp_up[act_sp.gen_redispatchable] - nb_redisp = act_sp.gen_redispatchable.sum() - nb_curtail = act_sp.gen_renewable.sum() + low_gen = -1.0 * act_sp_cls.gen_max_ramp_down[act_sp_cls.gen_redispatchable] + high_gen = 1.0 * act_sp_cls.gen_max_ramp_up[act_sp_cls.gen_redispatchable] + nb_redisp = act_sp_cls.gen_redispatchable.sum() + nb_curtail = act_sp_cls.gen_renewable.sum() curtail = np.full(shape=(nb_curtail,), fill_value=0.0, dtype=dt_float) curtail_mw = np.full(shape=(nb_curtail,), fill_value=0.0, dtype=dt_float) self._dict_properties = { "set_line_status": ( - np.full(shape=(act_sp.n_line,), fill_value=-1, dtype=dt_int), - np.full(shape=(act_sp.n_line,), fill_value=1, dtype=dt_int), - (act_sp.n_line,), + np.full(shape=(act_sp_cls.n_line,), fill_value=-1, dtype=dt_int), + np.full(shape=(act_sp_cls.n_line,), fill_value=1, dtype=dt_int), + (act_sp_cls.n_line,), dt_int, ), "change_line_status": ( - np.full(shape=(act_sp.n_line,), fill_value=0, dtype=dt_int), - np.full(shape=(act_sp.n_line,), fill_value=1, dtype=dt_int), - (act_sp.n_line,), + np.full(shape=(act_sp_cls.n_line,), fill_value=0, dtype=dt_int), + np.full(shape=(act_sp_cls.n_line,), fill_value=1, dtype=dt_int), + (act_sp_cls.n_line,), dt_int, ), "set_bus": ( - np.full(shape=(act_sp.dim_topo,), fill_value=-1, dtype=dt_int), - np.full(shape=(act_sp.dim_topo,), fill_value=1, dtype=dt_int), - (act_sp.dim_topo,), + np.full(shape=(act_sp_cls.dim_topo,), fill_value=-1, dtype=dt_int), + np.full(shape=(act_sp_cls.dim_topo,), fill_value=act_sp_cls.n_busbar_per_sub, dtype=dt_int), + (act_sp_cls.dim_topo,), dt_int, ), "change_bus": ( - np.full(shape=(act_sp.dim_topo,), fill_value=0, dtype=dt_int), - np.full(shape=(act_sp.dim_topo,), fill_value=1, dtype=dt_int), - (act_sp.dim_topo,), + np.full(shape=(act_sp_cls.dim_topo,), fill_value=0, dtype=dt_int), + np.full(shape=(act_sp_cls.dim_topo,), fill_value=1, dtype=dt_int), + (act_sp_cls.dim_topo,), dt_int, ), "redispatch": (low_gen, high_gen, (nb_redisp,), dt_float), "set_storage": ( - -1.0 * act_sp.storage_max_p_prod, - 1.0 * act_sp.storage_max_p_absorb, - (act_sp.n_storage,), + -1.0 * act_sp_cls.storage_max_p_prod, + 1.0 * act_sp_cls.storage_max_p_absorb, + (act_sp_cls.n_storage,), dt_float, ), "curtail": ( @@ -274,20 +296,20 @@ def __init__( ), "curtail_mw": ( curtail_mw, - 1.0 * act_sp.gen_pmax[act_sp.gen_renewable], + 1.0 * act_sp_cls.gen_pmax[act_sp_cls.gen_renewable], (nb_curtail,), dt_float, ), "raise_alarm": ( - np.full(shape=(act_sp.dim_alarms,), fill_value=0, dtype=dt_int), - np.full(shape=(act_sp.dim_alarms,), fill_value=1, dtype=dt_int), - (act_sp.dim_alarms,), + np.full(shape=(act_sp_cls.dim_alarms,), fill_value=0, dtype=dt_int), + np.full(shape=(act_sp_cls.dim_alarms,), fill_value=1, dtype=dt_int), + (act_sp_cls.dim_alarms,), dt_int, ), "raise_alert": ( - np.full(shape=(act_sp.dim_alerts,), fill_value=0, dtype=dt_int), - np.full(shape=(act_sp.dim_alerts,), fill_value=1, dtype=dt_int), - (act_sp.dim_alerts,), + np.full(shape=(act_sp_cls.dim_alerts,), fill_value=0, dtype=dt_int), + np.full(shape=(act_sp_cls.dim_alerts,), fill_value=1, dtype=dt_int), + (act_sp_cls.dim_alerts,), dt_int, ), } @@ -449,7 +471,7 @@ def _get_info(self, functs): if el in self._multiply: # special case if a 0 were entered arr_ = 1.0 * self._multiply[el] - is_nzero = arr_ != 0.0 + is_nzero = np.abs(arr_) >= 1e-7 low_ = 1.0 * low_.astype(dtype) high_ = 1.0 * high_.astype(dtype) @@ -520,7 +542,7 @@ def _handle_attribute(self, res, gym_act_this, attr_nm): setattr(res, attr_nm, gym_act_this) return res - def get_indexes(self, key: str) -> Tuple[int, int]: + def get_indexes(self, key: POSSIBLE_KEYS) -> Tuple[int, int]: """Allows to retrieve the indexes of the gym action that are concerned by the attribute name `key` given in input. @@ -563,7 +585,7 @@ def get_indexes(self, key: str) -> Tuple[int, int]: prev = where_to_put raise Grid2OpException(error_msg) - def from_gym(self, gym_act): + def from_gym(self, gym_act: np.ndarray) -> BaseAction: """ This is the function that is called to transform a gym action (in this case a numpy array!) sent by the agent @@ -607,10 +629,14 @@ def from_gym(self, gym_act): prev = where_to_put return res - def close(self): + def close(self) -> None: + """If you override this class, this function is called when the GymEnv is deleted. + + You can use it to free some memory if needed, but there is nothing to do in the general case. + """ pass - def normalize_attr(self, attr_nm: str): + def normalize_attr(self, attr_nm: POSSIBLE_KEYS)-> None: """ This function normalizes the part of the space that corresponds to the attribute `attr_nm`. diff --git a/grid2op/gym_compat/box_gym_obsspace.py b/grid2op/gym_compat/box_gym_obsspace.py index 0277a1517..76879ef9e 100644 --- a/grid2op/gym_compat/box_gym_obsspace.py +++ b/grid2op/gym_compat/box_gym_obsspace.py @@ -214,6 +214,7 @@ def __init__( self._attr_to_keep = sorted(attr_to_keep) ob_sp = grid2op_observation_space + ob_sp_cls = type(grid2op_observation_space) tol_redisp = ( ob_sp.obs_env._tol_poly ) # add to gen_p otherwise ... well it can crash @@ -263,113 +264,113 @@ def __init__( dt_int, ), "gen_p": ( - np.full(shape=(ob_sp.n_gen,), fill_value=0.0, dtype=dt_float) + np.full(shape=(ob_sp_cls.n_gen,), fill_value=0.0, dtype=dt_float) - tol_redisp - extra_for_losses, - ob_sp.gen_pmax + tol_redisp + extra_for_losses, - (ob_sp.n_gen,), + ob_sp_cls.gen_pmax + tol_redisp + extra_for_losses, + (ob_sp_cls.n_gen,), dt_float, ), "gen_q": ( - np.full(shape=(ob_sp.n_gen,), fill_value=-np.inf, dtype=dt_float), - np.full(shape=(ob_sp.n_gen,), fill_value=np.inf, dtype=dt_float), - (ob_sp.n_gen,), + np.full(shape=(ob_sp_cls.n_gen,), fill_value=-np.inf, dtype=dt_float), + np.full(shape=(ob_sp_cls.n_gen,), fill_value=np.inf, dtype=dt_float), + (ob_sp_cls.n_gen,), dt_float, ), "gen_v": ( - np.full(shape=(ob_sp.n_gen,), fill_value=0.0, dtype=dt_float), - np.full(shape=(ob_sp.n_gen,), fill_value=np.inf, dtype=dt_float), - (ob_sp.n_gen,), + np.full(shape=(ob_sp_cls.n_gen,), fill_value=0.0, dtype=dt_float), + np.full(shape=(ob_sp_cls.n_gen,), fill_value=np.inf, dtype=dt_float), + (ob_sp_cls.n_gen,), dt_float, ), "gen_margin_up": ( - np.full(shape=(ob_sp.n_gen,), fill_value=0.0, dtype=dt_float), - 1.0 * ob_sp.gen_max_ramp_up, - (ob_sp.n_gen,), + np.full(shape=(ob_sp_cls.n_gen,), fill_value=0.0, dtype=dt_float), + 1.0 * ob_sp_cls.gen_max_ramp_up, + (ob_sp_cls.n_gen,), dt_float, ), "gen_margin_down": ( - np.full(shape=(ob_sp.n_gen,), fill_value=0.0, dtype=dt_float), - 1.0 * ob_sp.gen_max_ramp_down, - (ob_sp.n_gen,), + np.full(shape=(ob_sp_cls.n_gen,), fill_value=0.0, dtype=dt_float), + 1.0 * ob_sp_cls.gen_max_ramp_down, + (ob_sp_cls.n_gen,), dt_float, ), "gen_theta": ( - np.full(shape=(ob_sp.n_gen,), fill_value=-180., dtype=dt_float), - np.full(shape=(ob_sp.n_gen,), fill_value=180., dtype=dt_float), - (ob_sp.n_gen,), + np.full(shape=(ob_sp_cls.n_gen,), fill_value=-180., dtype=dt_float), + np.full(shape=(ob_sp_cls.n_gen,), fill_value=180., dtype=dt_float), + (ob_sp_cls.n_gen,), dt_float, ), "load_p": ( - np.full(shape=(ob_sp.n_load,), fill_value=-np.inf, dtype=dt_float), - np.full(shape=(ob_sp.n_load,), fill_value=+np.inf, dtype=dt_float), - (ob_sp.n_load,), + np.full(shape=(ob_sp_cls.n_load,), fill_value=-np.inf, dtype=dt_float), + np.full(shape=(ob_sp_cls.n_load,), fill_value=+np.inf, dtype=dt_float), + (ob_sp_cls.n_load,), dt_float, ), "load_q": ( - np.full(shape=(ob_sp.n_load,), fill_value=-np.inf, dtype=dt_float), - np.full(shape=(ob_sp.n_load,), fill_value=+np.inf, dtype=dt_float), - (ob_sp.n_load,), + np.full(shape=(ob_sp_cls.n_load,), fill_value=-np.inf, dtype=dt_float), + np.full(shape=(ob_sp_cls.n_load,), fill_value=+np.inf, dtype=dt_float), + (ob_sp_cls.n_load,), dt_float, ), "load_v": ( - np.full(shape=(ob_sp.n_load,), fill_value=0.0, dtype=dt_float), - np.full(shape=(ob_sp.n_load,), fill_value=np.inf, dtype=dt_float), - (ob_sp.n_load,), + np.full(shape=(ob_sp_cls.n_load,), fill_value=0.0, dtype=dt_float), + np.full(shape=(ob_sp_cls.n_load,), fill_value=np.inf, dtype=dt_float), + (ob_sp_cls.n_load,), dt_float, ), "load_theta": ( - np.full(shape=(ob_sp.n_load,), fill_value=-180., dtype=dt_float), - np.full(shape=(ob_sp.n_load,), fill_value=180., dtype=dt_float), - (ob_sp.n_load,), + np.full(shape=(ob_sp_cls.n_load,), fill_value=-180., dtype=dt_float), + np.full(shape=(ob_sp_cls.n_load,), fill_value=180., dtype=dt_float), + (ob_sp_cls.n_load,), dt_float, ), "p_or": ( - np.full(shape=(ob_sp.n_line,), fill_value=-np.inf, dtype=dt_float), - np.full(shape=(ob_sp.n_line,), fill_value=np.inf, dtype=dt_float), - (ob_sp.n_line,), + np.full(shape=(ob_sp_cls.n_line,), fill_value=-np.inf, dtype=dt_float), + np.full(shape=(ob_sp_cls.n_line,), fill_value=np.inf, dtype=dt_float), + (ob_sp_cls.n_line,), dt_float, ), "q_or": ( - np.full(shape=(ob_sp.n_line,), fill_value=-np.inf, dtype=dt_float), - np.full(shape=(ob_sp.n_line,), fill_value=np.inf, dtype=dt_float), - (ob_sp.n_line,), + np.full(shape=(ob_sp_cls.n_line,), fill_value=-np.inf, dtype=dt_float), + np.full(shape=(ob_sp_cls.n_line,), fill_value=np.inf, dtype=dt_float), + (ob_sp_cls.n_line,), dt_float, ), "a_or": ( - np.full(shape=(ob_sp.n_line,), fill_value=0.0, dtype=dt_float), - np.full(shape=(ob_sp.n_line,), fill_value=np.inf, dtype=dt_float), - (ob_sp.n_line,), + np.full(shape=(ob_sp_cls.n_line,), fill_value=0.0, dtype=dt_float), + np.full(shape=(ob_sp_cls.n_line,), fill_value=np.inf, dtype=dt_float), + (ob_sp_cls.n_line,), dt_float, ), "v_or": ( - np.full(shape=(ob_sp.n_line,), fill_value=0.0, dtype=dt_float), - np.full(shape=(ob_sp.n_line,), fill_value=np.inf, dtype=dt_float), - (ob_sp.n_line,), + np.full(shape=(ob_sp_cls.n_line,), fill_value=0.0, dtype=dt_float), + np.full(shape=(ob_sp_cls.n_line,), fill_value=np.inf, dtype=dt_float), + (ob_sp_cls.n_line,), dt_float, ), "theta_or": ( - np.full(shape=(ob_sp.n_line,), fill_value=-180., dtype=dt_float), - np.full(shape=(ob_sp.n_line,), fill_value=180., dtype=dt_float), - (ob_sp.n_line,), + np.full(shape=(ob_sp_cls.n_line,), fill_value=-180., dtype=dt_float), + np.full(shape=(ob_sp_cls.n_line,), fill_value=180., dtype=dt_float), + (ob_sp_cls.n_line,), dt_float, ), "p_ex": ( - np.full(shape=(ob_sp.n_line,), fill_value=-np.inf, dtype=dt_float), - np.full(shape=(ob_sp.n_line,), fill_value=np.inf, dtype=dt_float), - (ob_sp.n_line,), + np.full(shape=(ob_sp_cls.n_line,), fill_value=-np.inf, dtype=dt_float), + np.full(shape=(ob_sp_cls.n_line,), fill_value=np.inf, dtype=dt_float), + (ob_sp_cls.n_line,), dt_float, ), "q_ex": ( - np.full(shape=(ob_sp.n_line,), fill_value=-np.inf, dtype=dt_float), - np.full(shape=(ob_sp.n_line,), fill_value=np.inf, dtype=dt_float), - (ob_sp.n_line,), + np.full(shape=(ob_sp_cls.n_line,), fill_value=-np.inf, dtype=dt_float), + np.full(shape=(ob_sp_cls.n_line,), fill_value=np.inf, dtype=dt_float), + (ob_sp_cls.n_line,), dt_float, ), "a_ex": ( - np.full(shape=(ob_sp.n_line,), fill_value=0.0, dtype=dt_float), - np.full(shape=(ob_sp.n_line,), fill_value=np.inf, dtype=dt_float), - (ob_sp.n_line,), + np.full(shape=(ob_sp_cls.n_line,), fill_value=0.0, dtype=dt_float), + np.full(shape=(ob_sp_cls.n_line,), fill_value=np.inf, dtype=dt_float), + (ob_sp_cls.n_line,), dt_float, ), "v_ex": ( @@ -379,135 +380,135 @@ def __init__( dt_float, ), "theta_ex": ( - np.full(shape=(ob_sp.n_line,), fill_value=-180., dtype=dt_float), - np.full(shape=(ob_sp.n_line,), fill_value=180., dtype=dt_float), - (ob_sp.n_line,), + np.full(shape=(ob_sp_cls.n_line,), fill_value=-180., dtype=dt_float), + np.full(shape=(ob_sp_cls.n_line,), fill_value=180., dtype=dt_float), + (ob_sp_cls.n_line,), dt_float, ), "rho": ( - np.full(shape=(ob_sp.n_line,), fill_value=0.0, dtype=dt_float), - np.full(shape=(ob_sp.n_line,), fill_value=np.inf, dtype=dt_float), - (ob_sp.n_line,), + np.full(shape=(ob_sp_cls.n_line,), fill_value=0.0, dtype=dt_float), + np.full(shape=(ob_sp_cls.n_line,), fill_value=np.inf, dtype=dt_float), + (ob_sp_cls.n_line,), dt_float, ), "line_status": ( - np.full(shape=(ob_sp.n_line,), fill_value=0, dtype=dt_int), - np.full(shape=(ob_sp.n_line,), fill_value=1, dtype=dt_int), - (ob_sp.n_line,), + np.full(shape=(ob_sp_cls.n_line,), fill_value=0, dtype=dt_int), + np.full(shape=(ob_sp_cls.n_line,), fill_value=1, dtype=dt_int), + (ob_sp_cls.n_line,), dt_int, ), "timestep_overflow": ( np.full( - shape=(ob_sp.n_line,), fill_value=np.iinfo(dt_int).min, dtype=dt_int + shape=(ob_sp_cls.n_line,), fill_value=np.iinfo(dt_int).min, dtype=dt_int ), np.full( - shape=(ob_sp.n_line,), fill_value=np.iinfo(dt_int).max, dtype=dt_int + shape=(ob_sp_cls.n_line,), fill_value=np.iinfo(dt_int).max, dtype=dt_int ), - (ob_sp.n_line,), + (ob_sp_cls.n_line,), dt_int, ), "topo_vect": ( - np.full(shape=(ob_sp.dim_topo,), fill_value=-1, dtype=dt_int), - np.full(shape=(ob_sp.dim_topo,), fill_value=2, dtype=dt_int), - (ob_sp.dim_topo,), + np.full(shape=(ob_sp_cls.dim_topo,), fill_value=-1, dtype=dt_int), + np.full(shape=(ob_sp_cls.dim_topo,), fill_value=ob_sp_cls.n_busbar_per_sub, dtype=dt_int), + (ob_sp_cls.dim_topo,), dt_int, ), "time_before_cooldown_line": ( - np.full(shape=(ob_sp.n_line,), fill_value=0, dtype=dt_int), + np.full(shape=(ob_sp_cls.n_line,), fill_value=0, dtype=dt_int), np.full( - shape=(ob_sp.n_line,), fill_value=np.iinfo(dt_int).max, dtype=dt_int + shape=(ob_sp_cls.n_line,), fill_value=np.iinfo(dt_int).max, dtype=dt_int ), - (ob_sp.n_line,), + (ob_sp_cls.n_line,), dt_int, ), "time_before_cooldown_sub": ( - np.full(shape=(ob_sp.n_sub,), fill_value=0, dtype=dt_int), + np.full(shape=(ob_sp_cls.n_sub,), fill_value=0, dtype=dt_int), np.full( - shape=(ob_sp.n_sub,), fill_value=np.iinfo(dt_int).max, dtype=dt_int + shape=(ob_sp_cls.n_sub,), fill_value=np.iinfo(dt_int).max, dtype=dt_int ), - (ob_sp.n_sub,), + (ob_sp_cls.n_sub,), dt_int, ), "time_next_maintenance": ( - np.full(shape=(ob_sp.n_line,), fill_value=-1, dtype=dt_int), + np.full(shape=(ob_sp_cls.n_line,), fill_value=-1, dtype=dt_int), np.full( - shape=(ob_sp.n_line,), fill_value=np.iinfo(dt_int).max, dtype=dt_int + shape=(ob_sp_cls.n_line,), fill_value=np.iinfo(dt_int).max, dtype=dt_int ), - (ob_sp.n_line,), + (ob_sp_cls.n_line,), dt_int, ), "duration_next_maintenance": ( - np.full(shape=(ob_sp.n_line,), fill_value=0, dtype=dt_int), + np.full(shape=(ob_sp_cls.n_line,), fill_value=0, dtype=dt_int), np.full( - shape=(ob_sp.n_line,), fill_value=np.iinfo(dt_int).max, dtype=dt_int + shape=(ob_sp_cls.n_line,), fill_value=np.iinfo(dt_int).max, dtype=dt_int ), - (ob_sp.n_line,), + (ob_sp_cls.n_line,), dt_int, ), "target_dispatch": ( - np.minimum(ob_sp.gen_pmin, -ob_sp.gen_pmax), - np.maximum(-ob_sp.gen_pmin, +ob_sp.gen_pmax), - (ob_sp.n_gen,), + np.minimum(ob_sp_cls.gen_pmin, -ob_sp_cls.gen_pmax), + np.maximum(-ob_sp_cls.gen_pmin, +ob_sp_cls.gen_pmax), + (ob_sp_cls.n_gen,), dt_float, ), "actual_dispatch": ( - np.minimum(ob_sp.gen_pmin, -ob_sp.gen_pmax), - np.maximum(-ob_sp.gen_pmin, +ob_sp.gen_pmax), - (ob_sp.n_gen,), + np.minimum(ob_sp_cls.gen_pmin, -ob_sp_cls.gen_pmax), + np.maximum(-ob_sp_cls.gen_pmin, +ob_sp_cls.gen_pmax), + (ob_sp_cls.n_gen,), dt_float, ), "storage_charge": ( - np.full(shape=(ob_sp.n_storage,), fill_value=0, dtype=dt_float), - 1.0 * ob_sp.storage_Emax, - (ob_sp.n_storage,), + np.full(shape=(ob_sp_cls.n_storage,), fill_value=0, dtype=dt_float), + 1.0 * ob_sp_cls.storage_Emax, + (ob_sp_cls.n_storage,), dt_float, ), "storage_power_target": ( - -1.0 * ob_sp.storage_max_p_prod, - 1.0 * ob_sp.storage_max_p_absorb, - (ob_sp.n_storage,), + -1.0 * ob_sp_cls.storage_max_p_prod, + 1.0 * ob_sp_cls.storage_max_p_absorb, + (ob_sp_cls.n_storage,), dt_float, ), "storage_power": ( - -1.0 * ob_sp.storage_max_p_prod, - 1.0 * ob_sp.storage_max_p_absorb, - (ob_sp.n_storage,), + -1.0 * ob_sp_cls.storage_max_p_prod, + 1.0 * ob_sp_cls.storage_max_p_absorb, + (ob_sp_cls.n_storage,), dt_float, ), "storage_theta": ( - np.full(shape=(ob_sp.n_storage,), fill_value=-180., dtype=dt_float), - np.full(shape=(ob_sp.n_storage,), fill_value=180., dtype=dt_float), - (ob_sp.n_storage,), + np.full(shape=(ob_sp_cls.n_storage,), fill_value=-180., dtype=dt_float), + np.full(shape=(ob_sp_cls.n_storage,), fill_value=180., dtype=dt_float), + (ob_sp_cls.n_storage,), dt_float, ), "curtailment": ( - np.full(shape=(ob_sp.n_gen,), fill_value=0.0, dtype=dt_float), - np.full(shape=(ob_sp.n_gen,), fill_value=1.0, dtype=dt_float), - (ob_sp.n_gen,), + np.full(shape=(ob_sp_cls.n_gen,), fill_value=0.0, dtype=dt_float), + np.full(shape=(ob_sp_cls.n_gen,), fill_value=1.0, dtype=dt_float), + (ob_sp_cls.n_gen,), dt_float, ), "curtailment_limit": ( - np.full(shape=(ob_sp.n_gen,), fill_value=0.0, dtype=dt_float), - np.full(shape=(ob_sp.n_gen,), fill_value=1.0, dtype=dt_float), - (ob_sp.n_gen,), + np.full(shape=(ob_sp_cls.n_gen,), fill_value=0.0, dtype=dt_float), + np.full(shape=(ob_sp_cls.n_gen,), fill_value=1.0, dtype=dt_float), + (ob_sp_cls.n_gen,), dt_float, ), "curtailment_mw": ( - np.full(shape=(ob_sp.n_gen,), fill_value=0.0, dtype=dt_float), - 1.0 * ob_sp.gen_pmax, - (ob_sp.n_gen,), + np.full(shape=(ob_sp_cls.n_gen,), fill_value=0.0, dtype=dt_float), + 1.0 * ob_sp_cls.gen_pmax, + (ob_sp_cls.n_gen,), dt_float, ), "curtailment_limit_mw": ( - np.full(shape=(ob_sp.n_gen,), fill_value=0.0, dtype=dt_float), - 1.0 * ob_sp.gen_pmax, - (ob_sp.n_gen,), + np.full(shape=(ob_sp_cls.n_gen,), fill_value=0.0, dtype=dt_float), + 1.0 * ob_sp_cls.gen_pmax, + (ob_sp_cls.n_gen,), dt_float, ), "thermal_limit": ( - np.full(shape=(ob_sp.n_line,), fill_value=0.0, dtype=dt_float), - np.full(shape=(ob_sp.n_line,), fill_value=np.inf, dtype=dt_float), - (ob_sp.n_line,), + np.full(shape=(ob_sp_cls.n_line,), fill_value=0.0, dtype=dt_float), + np.full(shape=(ob_sp_cls.n_line,), fill_value=np.inf, dtype=dt_float), + (ob_sp_cls.n_line,), dt_float, ), "is_alarm_illegal": ( @@ -523,13 +524,13 @@ def __init__( dt_int, ), "last_alarm": ( - np.full(shape=(ob_sp.dim_alarms,), fill_value=-1, dtype=dt_int), + np.full(shape=(ob_sp_cls.dim_alarms,), fill_value=-1, dtype=dt_int), np.full( - shape=(ob_sp.dim_alarms,), + shape=(ob_sp_cls.dim_alarms,), fill_value=np.iinfo(dt_int).max, dtype=dt_int, ), - (ob_sp.dim_alarms,), + (ob_sp_cls.dim_alarms,), dt_int, ), "attention_budget": ( @@ -552,45 +553,45 @@ def __init__( ), # alert stuff "active_alert": ( - np.full(shape=(ob_sp.dim_alerts,), fill_value=False, dtype=dt_bool), - np.full(shape=(ob_sp.dim_alerts,), fill_value=True, dtype=dt_bool), - (ob_sp.dim_alerts,), + np.full(shape=(ob_sp_cls.dim_alerts,), fill_value=False, dtype=dt_bool), + np.full(shape=(ob_sp_cls.dim_alerts,), fill_value=True, dtype=dt_bool), + (ob_sp_cls.dim_alerts,), dt_bool, ), "time_since_last_alert": ( - np.full(shape=(ob_sp.dim_alerts,), fill_value=-1, dtype=dt_int), - np.full(shape=(ob_sp.dim_alerts,), fill_value=np.iinfo(dt_int).max, dtype=dt_int), - (ob_sp.dim_alerts,), + np.full(shape=(ob_sp_cls.dim_alerts,), fill_value=-1, dtype=dt_int), + np.full(shape=(ob_sp_cls.dim_alerts,), fill_value=np.iinfo(dt_int).max, dtype=dt_int), + (ob_sp_cls.dim_alerts,), dt_int, ), "alert_duration": ( - np.full(shape=(ob_sp.dim_alerts,), fill_value=-1, dtype=dt_int), - np.full(shape=(ob_sp.dim_alerts,), fill_value=np.iinfo(dt_int).max, dtype=dt_int), - (ob_sp.dim_alerts,), + np.full(shape=(ob_sp_cls.dim_alerts,), fill_value=-1, dtype=dt_int), + np.full(shape=(ob_sp_cls.dim_alerts,), fill_value=np.iinfo(dt_int).max, dtype=dt_int), + (ob_sp_cls.dim_alerts,), dt_int, ), "total_number_of_alert": ( - np.full(shape=(1 if ob_sp.dim_alerts else 0,), fill_value=-1, dtype=dt_int), - np.full(shape=(1 if ob_sp.dim_alerts else 0,), fill_value=np.iinfo(dt_int).max, dtype=dt_int), - (1 if ob_sp.dim_alerts else 0,), + np.full(shape=(1 if ob_sp_cls.dim_alerts else 0,), fill_value=-1, dtype=dt_int), + np.full(shape=(1 if ob_sp_cls.dim_alerts else 0,), fill_value=np.iinfo(dt_int).max, dtype=dt_int), + (1 if ob_sp_cls.dim_alerts else 0,), dt_int, ), "time_since_last_attack": ( - np.full(shape=(ob_sp.dim_alerts,), fill_value=-1, dtype=dt_int), - np.full(shape=(ob_sp.dim_alerts,), fill_value=np.iinfo(dt_int).max, dtype=dt_int), - (ob_sp.dim_alerts,), + np.full(shape=(ob_sp_cls.dim_alerts,), fill_value=-1, dtype=dt_int), + np.full(shape=(ob_sp_cls.dim_alerts,), fill_value=np.iinfo(dt_int).max, dtype=dt_int), + (ob_sp_cls.dim_alerts,), dt_int, ), "was_alert_used_after_attack": ( - np.full(shape=(ob_sp.dim_alerts,), fill_value=-1, dtype=dt_int), - np.full(shape=(ob_sp.dim_alerts,), fill_value=1, dtype=dt_int), - (ob_sp.dim_alerts,), + np.full(shape=(ob_sp_cls.dim_alerts,), fill_value=-1, dtype=dt_int), + np.full(shape=(ob_sp_cls.dim_alerts,), fill_value=1, dtype=dt_int), + (ob_sp_cls.dim_alerts,), dt_int, ), "attack_under_alert": ( - np.full(shape=(ob_sp.dim_alerts,), fill_value=-1, dtype=dt_int), - np.full(shape=(ob_sp.dim_alerts,), fill_value=1, dtype=dt_int), - (ob_sp.dim_alerts,), + np.full(shape=(ob_sp_cls.dim_alerts,), fill_value=-1, dtype=dt_int), + np.full(shape=(ob_sp_cls.dim_alerts,), fill_value=1, dtype=dt_int), + (ob_sp_cls.dim_alerts,), dt_int, ), } diff --git a/grid2op/gym_compat/discrete_gym_actspace.py b/grid2op/gym_compat/discrete_gym_actspace.py index e059a04b8..4e89c448a 100644 --- a/grid2op/gym_compat/discrete_gym_actspace.py +++ b/grid2op/gym_compat/discrete_gym_actspace.py @@ -8,10 +8,10 @@ import copy import warnings -# from gym.spaces import Discrete +from typing import Literal, Dict, Tuple, Any, Optional from grid2op.Exceptions import Grid2OpException -from grid2op.Action import ActionSpace +from grid2op.Action import ActionSpace, BaseAction from grid2op.Converter import IdToAct from grid2op.gym_compat.utils import (ALL_ATTR_FOR_DISCRETE, @@ -19,12 +19,6 @@ GYM_AVAILABLE, GYMNASIUM_AVAILABLE) -# TODO test that it works normally -# TODO test the casting in dt_int or dt_float depending on the data -# TODO test the scaling -# TODO doc -# TODO test the function part - class __AuxDiscreteActSpace: """ @@ -215,9 +209,18 @@ class __AuxDiscreteActSpace: def __init__( self, - grid2op_action_space, - attr_to_keep=ALL_ATTR_FOR_DISCRETE, - nb_bins=None, + grid2op_action_space : ActionSpace, + attr_to_keep: Optional[Tuple[Literal["set_line_status"], + Literal["set_line_status_simple"], + Literal["change_line_status"], + Literal["set_bus"], + Literal["change_bus"], + Literal["redispatch"], + Literal["set_storage"], + Literal["curtail"], + Literal["curtail_mw"], + ]]=ALL_ATTR_FOR_DISCRETE, + nb_bins : Dict[Literal["redispatch", "set_storage", "curtail", "curtail_mw"], int]=None, action_list=None, ): @@ -274,8 +277,6 @@ def __init__( "set_storage": act_sp.get_all_unitary_storage, "curtail": act_sp.get_all_unitary_curtail, "curtail_mw": act_sp.get_all_unitary_curtail, - # "raise_alarm": act_sp.get_all_unitary_alarm, - # "raise_alert": act_sp.get_all_unitary_alert, "set_line_status_simple": act_sp.get_all_unitary_line_set_simple, } @@ -319,7 +320,7 @@ def _get_info(self): self.converter = converter return self.converter.n - def from_gym(self, gym_act): + def from_gym(self, gym_act: int) -> BaseAction: """ This is the function that is called to transform a gym action (in this case a numpy array!) sent by the agent @@ -339,7 +340,11 @@ def from_gym(self, gym_act): res = self.converter.all_actions[int(gym_act)] return res - def close(self): + def close(self) -> None: + """If you override this class, this function is called when the GymEnv is deleted. + + You can use it to free some memory if needed, but there is nothing to do in the general case. + """ pass @@ -363,7 +368,7 @@ def close(self): from gymnasium.spaces import Discrete from grid2op.gym_compat.box_gym_actspace import BoxGymnasiumActSpace from grid2op.gym_compat.continuous_to_discrete import ContinuousToDiscreteConverterGymnasium - DiscreteActSpaceGymnasium = type("MultiDiscreteActSpaceGymnasium", + DiscreteActSpaceGymnasium = type("DiscreteActSpaceGymnasium", (__AuxDiscreteActSpace, Discrete, ), {"_gymnasium": True, "_DiscreteType": Discrete, diff --git a/grid2op/gym_compat/gym_act_space.py b/grid2op/gym_compat/gym_act_space.py index 8bc428e2e..94bf2ff0f 100644 --- a/grid2op/gym_compat/gym_act_space.py +++ b/grid2op/gym_compat/gym_act_space.py @@ -18,7 +18,7 @@ from grid2op.Action import BaseAction, ActionSpace from grid2op.dtypes import dt_int, dt_bool, dt_float from grid2op.Converter.Converters import Converter -from grid2op.gym_compat.utils import GYM_AVAILABLE, GYMNASIUM_AVAILABLE +from grid2op.gym_compat.utils import GYM_AVAILABLE, GYMNASIUM_AVAILABLE, ActType class __AuxGymActionSpace: @@ -248,7 +248,9 @@ def _fill_dict_act_space(self, dict_, action_space, dict_variables): if attr_nm == "_set_line_status": my_type = type(self)._BoxType(low=-1, high=1, shape=shape, dtype=dt) elif attr_nm == "_set_topo_vect": - my_type = type(self)._BoxType(low=-1, high=2, shape=shape, dtype=dt) + my_type = type(self)._BoxType(low=-1, + high=type(action_space).n_busbar_per_sub, + shape=shape, dtype=dt) elif dt == dt_bool: # boolean observation space my_type = self._boolean_type(sh) diff --git a/grid2op/gym_compat/gym_obs_space.py b/grid2op/gym_compat/gym_obs_space.py index d427f4230..f74b3e43a 100644 --- a/grid2op/gym_compat/gym_obs_space.py +++ b/grid2op/gym_compat/gym_obs_space.py @@ -252,7 +252,9 @@ def _fill_dict_obs_space( elif attr_nm == "day_of_week": my_type = type(self)._DiscreteType(n=8) elif attr_nm == "topo_vect": - my_type = type(self)._BoxType(low=-1, high=2, shape=shape, dtype=dt) + my_type = type(self)._BoxType(low=-1, + high=observation_space.n_busbar_per_sub, + shape=shape, dtype=dt) elif attr_nm == "time_before_cooldown_line": my_type = type(self)._BoxType( low=0, diff --git a/grid2op/gym_compat/gymenv.py b/grid2op/gym_compat/gymenv.py index 7531e52e8..b0325d797 100644 --- a/grid2op/gym_compat/gymenv.py +++ b/grid2op/gym_compat/gymenv.py @@ -7,11 +7,18 @@ # This file is part of Grid2Op, Grid2Op a testbed platform to model sequential decision making in power systems. import numpy as np +from typing import Literal, Dict, Tuple, Any, Optional, Union, Generic from grid2op.dtypes import dt_int from grid2op.Chronics import Multifolder -from grid2op.gym_compat.utils import GYM_AVAILABLE, GYMNASIUM_AVAILABLE -from grid2op.gym_compat.utils import (check_gym_version, sample_seed) +from grid2op.Environment import Environment +from grid2op.typing_variables import STEP_INFO_TYPING, RESET_OPTIONS_TYPING +from grid2op.gym_compat.utils import (GYM_AVAILABLE, + GYMNASIUM_AVAILABLE, + check_gym_version, + sample_seed, + ObsType, + ActType) def conditional_decorator(condition): @@ -22,8 +29,10 @@ def decorator(func): return NotImplementedError() # anything that is not a callbe anyway return decorator +_TIME_SERIE_ID = "time serie id" +RESET_INFO_GYM_TYPING = Dict[Literal["time serie id", "seed", "grid2op_env_seed", "underlying_env_seeds"], Any] -class __AuxGymEnv: +class __AuxGymEnv(Generic[ObsType, ActType]): """ fully implements the openAI gym API by using the :class:`GymActionSpace` and :class:`GymObservationSpace` for compliance with openAI gym. @@ -45,7 +54,7 @@ class behave differently depending on the version of gym you have installed ! - :class:`GymEnv_Modern` for gym >= 0.26 .. warning:: - Depending on the presence absence of gymnasium and gym packages this class might behave differently. + Depending on the presence absence of `gymnasium` and `gym` packages this class might behave differently. In grid2op we tried to maintain compatibility both with gymnasium (newest) and gym (legacy, no more maintained) RL packages. The behaviour is the following: @@ -95,7 +104,10 @@ class behave differently depending on the version of gym you have installed ! an action is represented through an OrderedDict (`from collection import OrderedDict`) """ - def __init__(self, env_init, shuffle_chronics=True, render_mode="rgb_array"): + def __init__(self, + env_init: Environment, + shuffle_chronics:Optional[bool]=True, + render_mode: Literal["rgb_array"]="rgb_array"): check_gym_version(type(self)._gymnasium) self.init_env = env_init.copy() self.action_space = type(self)._ActionSpaceType(self.init_env) @@ -110,14 +122,14 @@ def __init__(self, env_init, shuffle_chronics=True, render_mode="rgb_array"): # for older version of gym it does not exist self._np_random = np.random.RandomState() - def _aux_step(self, gym_action): + def _aux_step(self, gym_action: ActType) -> Tuple[ObsType, float, bool, STEP_INFO_TYPING]: # used for gym < 0.26 g2op_act = self.action_space.from_gym(gym_action) g2op_obs, reward, done, info = self.init_env.step(g2op_act) gym_obs = self.observation_space.to_gym(g2op_obs) return gym_obs, float(reward), done, info - def _aux_step_new(self, gym_action): + def _aux_step_new(self, gym_action: ActType) -> Tuple[ObsType, float, bool, bool, STEP_INFO_TYPING]: # used for gym >= 0.26 # TODO refacto with _aux_step g2op_act = self.action_space.from_gym(gym_action) @@ -126,7 +138,10 @@ def _aux_step_new(self, gym_action): truncated = False # see https://github.com/openai/gym/pull/2752 return gym_obs, float(reward), terminated, truncated, info - def _aux_reset(self, seed=None, return_info=None, options=None): + def _aux_reset(self, + seed: Optional[int]=None, + return_info: Optional[bool]=None, + options: Optional[Dict[Any, Any]]=None) -> Union[ObsType, Tuple[ObsType, RESET_INFO_GYM_TYPING]]: # used for gym < 0.26 if self._shuffle_chronics and isinstance( self.init_env.chronics_handler.real_data, Multifolder @@ -141,7 +156,7 @@ def _aux_reset(self, seed=None, return_info=None, options=None): if return_info: chron_id = self.init_env.chronics_handler.get_id() - info = {"time serie id": chron_id} + info = {_TIME_SERIE_ID: chron_id} if seed is not None: info["seed"] = seed info["grid2op_env_seed"] = next_seed @@ -150,11 +165,13 @@ def _aux_reset(self, seed=None, return_info=None, options=None): else: return gym_obs - def _aux_reset_new(self, seed=None, options=None): + def _aux_reset_new(self, + seed: Optional[int]=None, + options: RESET_OPTIONS_TYPING=None) -> Tuple[ObsType,RESET_INFO_GYM_TYPING]: # used for gym > 0.26 - if self._shuffle_chronics and isinstance( - self.init_env.chronics_handler.real_data, Multifolder - ) and (options is not None and "time serie id" not in options): + if (self._shuffle_chronics and + isinstance(self.init_env.chronics_handler.real_data, Multifolder) and + (options is not None and _TIME_SERIE_ID not in options)): self.init_env.chronics_handler.sample_next_chronics() super().reset(seed=seed) # seed gymnasium env @@ -168,7 +185,7 @@ def _aux_reset_new(self, seed=None, options=None): gym_obs = self.observation_space.to_gym(g2op_obs) chron_id = self.init_env.chronics_handler.get_id() - info = {"time serie id": chron_id} + info = {_TIME_SERIE_ID: chron_id} if seed is not None: info["seed"] = seed info["grid2op_env_seed"] = next_seed @@ -179,7 +196,7 @@ def render(self): """for compatibility with open ai gym render function""" return self.init_env.render() - def close(self): + def close(self) -> None: if hasattr(self, "init_env") and self.init_env is not None: self.init_env.close() del self.init_env @@ -207,7 +224,7 @@ def _aux_seed_g2op(self, seed): underlying_env_seeds = self.init_env.seed(next_seed) return seed, next_seed, underlying_env_seeds - def _aux_seed(self, seed=None): + def _aux_seed(self, seed: Optional[int]=None): # deprecated in gym >=0.26 if seed is not None: # seed the gym env @@ -234,13 +251,13 @@ def __del__(self): _AuxGymEnv.__doc__ = __AuxGymEnv.__doc__ class GymEnv_Legacy(_AuxGymEnv): # for old version of gym - def reset(self, *args, **kwargs): + def reset(self, *args, **kwargs) -> ObsType: return self._aux_reset(*args, **kwargs) - def step(self, action): + def step(self, action: ActType) -> Tuple[ObsType, float, bool, STEP_INFO_TYPING]: return self._aux_step(action) - def seed(self, seed): + def seed(self, seed: Optional[int]) -> None: # defined only on some cases return self._aux_seed(seed) @@ -248,12 +265,15 @@ def seed(self, seed): class GymEnv_Modern(_AuxGymEnv): # for new version of gym def reset(self, - *, - seed=None, - options=None,): + *, + seed: Optional[int]=None, + options: RESET_OPTIONS_TYPING = None) -> Tuple[ + ObsType, + RESET_INFO_GYM_TYPING + ]: return self._aux_reset_new(seed, options) - def step(self, action): + def step(self, action : ActType) -> Tuple[ObsType, float, bool, bool, STEP_INFO_TYPING]: return self._aux_step_new(action) GymEnv_Legacy.__doc__ = __AuxGymEnv.__doc__ GymEnv_Modern.__doc__ = __AuxGymEnv.__doc__ @@ -272,13 +292,16 @@ def step(self, action): _AuxGymnasiumEnv.__doc__ = __AuxGymEnv.__doc__ class GymnasiumEnv(_AuxGymnasiumEnv): - # for new version of gym + # for gymnasium def reset(self, - *, - seed=None, - options=None,): + *, + seed: Optional[int]=None, + options: RESET_OPTIONS_TYPING = None) -> Tuple[ + ObsType, + RESET_INFO_GYM_TYPING + ]: return self._aux_reset_new(seed, options) - def step(self, action): + def step(self, action: ActType) -> Tuple[ObsType, float, bool, bool, STEP_INFO_TYPING]: return self._aux_step_new(action) - GymnasiumEnv.__doc__ = __AuxGymEnv.__doc__ \ No newline at end of file + GymnasiumEnv.__doc__ = __AuxGymEnv.__doc__ diff --git a/grid2op/gym_compat/multidiscrete_gym_actspace.py b/grid2op/gym_compat/multidiscrete_gym_actspace.py index a92620389..60999fd9c 100644 --- a/grid2op/gym_compat/multidiscrete_gym_actspace.py +++ b/grid2op/gym_compat/multidiscrete_gym_actspace.py @@ -9,9 +9,11 @@ import copy import warnings import numpy as np +from typing import Literal, Dict, Tuple, Any, Optional from grid2op.Action import ActionSpace from grid2op.dtypes import dt_int, dt_bool, dt_float +from grid2op.Exceptions import Grid2OpException from grid2op.gym_compat.utils import (ALL_ATTR, ATTR_DISCRETE, @@ -39,7 +41,7 @@ class __AuxMultiDiscreteActSpace: - "change_line_status": `n_line` dimensions, each containing 2 elements "CHANGE", "DONT CHANGE" and affecting the powerline status (connected / disconnected) - "set_bus": `dim_topo` dimensions, each containing 4 choices: "DISCONNECT", "DONT AFFECT", "CONNECT TO BUSBAR 1", - or "CONNECT TO BUSBAR 2" and affecting to which busbar an object is connected + or "CONNECT TO BUSBAR 2", "CONNECT TO BUSBAR 3", ... and affecting to which busbar an object is connected - "change_bus": `dim_topo` dimensions, each containing 2 choices: "CHANGE", "DONT CHANGE" and affect to which busbar an element is connected - "redispatch": `sum(env.gen_redispatchable)` dimensions, each containing a certain number of choices depending on the value @@ -66,6 +68,12 @@ class __AuxMultiDiscreteActSpace: - "one_sub_set": 1 single dimension. This type of representation differs from the previous one only by the fact that each step you can perform only one single action on a single substation (so unlikely to be illegal). - "one_sub_change": 1 single dimension. Same as above. + - "one_line_set": 1 single dimension. In this type of representation, you have one dimension with `1 + 2 * n_line` + elements: first is "do nothing", then next elements control the force connection or disconnection + of the powerlines (new in version 1.10.0) + - "one_line_change": 1 single dimension. In this type of representation, you have `1 + n_line` possibility + for this element. First one is "do nothing" then it controls the change of status of + any given line (new in version 1.10.0). .. warning:: @@ -169,7 +177,25 @@ class __AuxMultiDiscreteActSpace: ATTR_NEEDBUILD = 2 ATTR_NEEDBINARIZED = 3 - def __init__(self, grid2op_action_space, attr_to_keep=ALL_ATTR, nb_bins=None): + def __init__(self, + grid2op_action_space: ActionSpace, + attr_to_keep: Optional[Tuple[Literal["set_line_status"], + Literal["change_line_status"], + Literal["set_bus"], + Literal["sub_set_bus"], + Literal["one_sub_set"], + Literal["change_bus"], + Literal["sub_change_bus"], + Literal["one_sub_change"], + Literal["redispatch"], + Literal["set_storage"], + Literal["curtail"], + Literal["curtail_mw"], + Literal["one_line_set"], + Literal["one_line_change"], + ]]=ALL_ATTR, + nb_bins: Dict[Literal["redispatch", "set_storage", "curtail", "curtail_mw"], int]=None + ): check_gym_version(type(self)._gymnasium) if not isinstance(grid2op_action_space, ActionSpace): raise RuntimeError( @@ -188,7 +214,6 @@ def __init__(self, grid2op_action_space, attr_to_keep=ALL_ATTR, nb_bins=None): attr_to_keep = { el for el in attr_to_keep if grid2op_action_space.supports_type(el) } - for el in attr_to_keep: if el not in ATTR_DISCRETE: warnings.warn( @@ -201,7 +226,7 @@ def __init__(self, grid2op_action_space, attr_to_keep=ALL_ATTR, nb_bins=None): self._attr_to_keep = sorted(attr_to_keep) - act_sp = grid2op_action_space + act_sp = type(grid2op_action_space) self._act_space = copy.deepcopy(grid2op_action_space) low_gen = -1.0 * act_sp.gen_max_ramp_down @@ -214,96 +239,108 @@ def __init__(self, grid2op_action_space, attr_to_keep=ALL_ATTR, nb_bins=None): "set_line_status": ( [3 for _ in range(act_sp.n_line)], act_sp.n_line, - self.ATTR_SET, + type(self).ATTR_SET, ), "change_line_status": ( [2 for _ in range(act_sp.n_line)], act_sp.n_line, - self.ATTR_CHANGE, + type(self).ATTR_CHANGE, ), "set_bus": ( - [4 for _ in range(act_sp.dim_topo)], + [2 + act_sp.n_busbar_per_sub for _ in range(act_sp.dim_topo)], act_sp.dim_topo, - self.ATTR_SET, + type(self).ATTR_SET, ), "change_bus": ( [2 for _ in range(act_sp.dim_topo)], act_sp.dim_topo, - self.ATTR_CHANGE, + type(self).ATTR_CHANGE, ), "raise_alarm": ( [2 for _ in range(act_sp.dim_alarms)], act_sp.dim_alarms, - self.ATTR_CHANGE, + type(self).ATTR_CHANGE, ), "raise_alert": ( [2 for _ in range(act_sp.dim_alerts)], act_sp.dim_alerts, - self.ATTR_CHANGE, + type(self).ATTR_CHANGE, ), "sub_set_bus": ( None, act_sp.n_sub, - self.ATTR_NEEDBUILD, - ), # dimension will be computed on the fly, if the stuff is used + type(self).ATTR_NEEDBUILD, + ), # dimension will be computed on the fly, if the kwarg is used "sub_change_bus": ( None, act_sp.n_sub, - self.ATTR_NEEDBUILD, - ), # dimension will be computed on the fly, if the stuff is used + type(self).ATTR_NEEDBUILD, + ), # dimension will be computed on the fly, if the kwarg is used "one_sub_set": ( None, 1, - self.ATTR_NEEDBUILD, - ), # dimension will be computed on the fly, if the stuff is used + type(self).ATTR_NEEDBUILD, + ), # dimension will be computed on the fly, if the kwarg is used "one_sub_change": ( None, 1, - self.ATTR_NEEDBUILD, - ), # dimension will be computed on the fly, if the stuff is used + type(self).ATTR_NEEDBUILD, + ), # dimension will be computed on the fly, if the kwarg is used + "one_line_set": ( + None, + 1, + type(self).ATTR_NEEDBUILD, + ), # dimension will be computed on the fly, if the kwarg is used + "one_line_change": ( + None, + 1, + type(self).ATTR_NEEDBUILD, + ), # dimension will be computed on the fly, if the kwarg is used } self._nb_bins = nb_bins for el in ["redispatch", "set_storage", "curtail", "curtail_mw"]: - if el in attr_to_keep: - if el not in nb_bins: - raise RuntimeError( - f'The attribute you want to keep "{el}" is not present in the ' - f'"nb_bins". This attribute is continuous, you have to specify in how ' - f"how to convert it to a discrete space. See the documentation " - f"for more information." - ) - nb_redispatch = act_sp.gen_redispatchable.sum() - nb_renew = act_sp.gen_renewable.sum() - if el == "redispatch": - self.dict_properties[el] = ( - [nb_bins[el] for _ in range(nb_redispatch)], - nb_redispatch, - self.ATTR_NEEDBINARIZED, - ) - elif el == "curtail" or el == "curtail_mw": - self.dict_properties[el] = ( - [nb_bins[el] for _ in range(nb_renew)], - nb_renew, - self.ATTR_NEEDBINARIZED, - ) - elif el == "set_storage": - self.dict_properties[el] = ( - [nb_bins[el] for _ in range(act_sp.n_storage)], - act_sp.n_storage, - self.ATTR_NEEDBINARIZED, - ) - else: - raise RuntimeError(f'Unknown attribute "{el}"') - + self._aux_check_continuous_elements(el, attr_to_keep, nb_bins, act_sp) + self._dims = None self._functs = None # final functions that is applied to the gym action to map it to a grid2Op action - self._binarizers = None # contains all the stuff to binarize the data + self._binarizers = None # contains all the kwarg to binarize the data self._types = None nvec = self._get_info() - # initialize the base container type(self)._MultiDiscreteType.__init__(self, nvec=nvec) + def _aux_check_continuous_elements(self, el, attr_to_keep, nb_bins, act_sp): + if el in attr_to_keep: + if el not in nb_bins: + raise RuntimeError( + f'The attribute you want to keep "{el}" is not present in the ' + f'"nb_bins". This attribute is continuous, you have to specify in how ' + f"how to convert it to a discrete space. See the documentation " + f"for more information." + ) + nb_redispatch = act_sp.gen_redispatchable.sum() + nb_renew = act_sp.gen_renewable.sum() + if el == "redispatch": + self.dict_properties[el] = ( + [nb_bins[el] for _ in range(nb_redispatch)], + nb_redispatch, + self.ATTR_NEEDBINARIZED, + ) + elif el == "curtail" or el == "curtail_mw": + self.dict_properties[el] = ( + [nb_bins[el] for _ in range(nb_renew)], + nb_renew, + self.ATTR_NEEDBINARIZED, + ) + elif el == "set_storage": + self.dict_properties[el] = ( + [nb_bins[el] for _ in range(act_sp.n_storage)], + act_sp.n_storage, + self.ATTR_NEEDBINARIZED, + ) + else: + raise Grid2OpException(f'Unknown attribute "{el}"') + @staticmethod def _funct_set(vect): # gym encodes: @@ -415,22 +452,41 @@ def _get_info(self): funct = self._funct_substations elif el == "one_sub_set": # an action change only one substation, using "set" - self._sub_modifiers[ - el - ] = self._act_space.get_all_unitary_topologies_set( + self._sub_modifiers[el] = [self._act_space()] + self._sub_modifiers[el] += self._act_space.get_all_unitary_topologies_set( self._act_space ) funct = self._funct_one_substation nvec_ = [len(self._sub_modifiers[el])] elif el == "one_sub_change": # an action change only one substation, using "change" + self._sub_modifiers[el] = [self._act_space()] self._sub_modifiers[ el - ] = self._act_space.get_all_unitary_topologies_change( + ] += self._act_space.get_all_unitary_topologies_change( self._act_space ) funct = self._funct_one_substation nvec_ = [len(self._sub_modifiers[el])] + elif el == "one_line_set": + # an action change only one substation, using "change" + self._sub_modifiers[el] = [self._act_space()] + tmp = [] + for l_id in range(type(self._act_space).n_line): + tmp.append(self._act_space({"set_line_status": [(l_id, +1)]})) + tmp.append(self._act_space({"set_line_status": [(l_id, -1)]})) + self._sub_modifiers[el] += tmp + funct = self._funct_one_substation + nvec_ = [len(self._sub_modifiers[el])] + elif el == "one_line_change": + # an action change only one substation, using "change" + self._sub_modifiers[el] = [self._act_space()] + tmp = [] + for l_id in range(type(self._act_space).n_line): + tmp.append(self._act_space({"change_line_status": [l_id]})) + self._sub_modifiers[el] += tmp + funct = self._funct_one_substation + nvec_ = [len(self._sub_modifiers[el])] else: raise RuntimeError( f'Unsupported attribute "{el}" when dealing with ' @@ -473,7 +529,7 @@ def _handle_attribute(self, res, gym_act_this, attr_nm, funct, type_): """ # TODO code that ! vect = 1 * gym_act_this - if type_ == self.ATTR_NEEDBUILD: + if type_ == type(self).ATTR_NEEDBUILD: funct(res, attr_nm, vect) else: tmp = funct(vect) diff --git a/grid2op/gym_compat/utils.py b/grid2op/gym_compat/utils.py index 2e42adac1..030fa89bb 100644 --- a/grid2op/gym_compat/utils.py +++ b/grid2op/gym_compat/utils.py @@ -29,7 +29,13 @@ GYMNASIUM_AVAILABLE = True except ImportError: GYMNASIUM_AVAILABLE = False - + +try: + from gymnasium.core import ObsType, ActType +except ImportError: + from typing import TypeVar + ObsType = TypeVar("ObsType") + ActType = TypeVar("ActType") _MIN_GYM_VERSION = version.parse("0.17.2") # this is the last gym version to use the "old" numpy prng @@ -69,6 +75,8 @@ "sub_change_bus", "one_sub_set", "one_sub_change", + "one_line_set", + "one_line_change" ) ALL_ATTR_CONT = ( @@ -103,11 +111,11 @@ def _compute_extra_power_for_losses(gridobj): def sample_seed(max_, np_random): """sample a seed based on gym version (np_random has not always the same behaviour)""" if GYM_VERSION <= _MAX_GYM_VERSION_RANDINT: - if hasattr(np_random, "randint"): + if hasattr(np_random, "integers"): + seed_ = int(np_random.integers(0, max_)) + else: # old gym behaviour seed_ = np_random.randint(max_) - else: - seed_ = int(np_random.integers(0, max_)) else: # gym finally use most recent numpy random generator seed_ = int(np_random.integers(0, max_)) diff --git a/grid2op/l2rpn_utils/wcci_2020.py b/grid2op/l2rpn_utils/wcci_2020.py index 9293326f6..3636fbfa0 100644 --- a/grid2op/l2rpn_utils/wcci_2020.py +++ b/grid2op/l2rpn_utils/wcci_2020.py @@ -18,50 +18,48 @@ class ActionWCCI2020(PlayableAction): "redispatch", } - attr_list_vect = [ - "_set_line_status", - "_switch_line_status", - "_set_topo_vect", - "_change_bus_vect", - '_redispatch' - ] + attr_list_vect = ['_set_line_status', + '_set_topo_vect', + '_change_bus_vect', + '_switch_line_status', + '_redispatch'] attr_list_set = set(attr_list_vect) pass class ObservationWCCI2020(CompleteObservation): attr_list_vect = [ - 'year', - 'month', - 'day', - 'hour_of_day', - 'minute_of_hour', - 'day_of_week', - "gen_p", - "gen_q", - "gen_v", - 'load_p', - 'load_q', - 'load_v', - 'p_or', - 'q_or', - 'v_or', - 'a_or', - 'p_ex', - 'q_ex', - 'v_ex', - 'a_ex', - 'rho', - 'line_status', - 'timestep_overflow', - 'topo_vect', - 'time_before_cooldown_line', - 'time_before_cooldown_sub', - 'time_next_maintenance', - 'duration_next_maintenance', - 'target_dispatch', - 'actual_dispatch' - ] + "year", + "month", + "day", + "hour_of_day", + "minute_of_hour", + "day_of_week", + "gen_p", + "gen_q", + "gen_v", + "load_p", + "load_q", + "load_v", + "p_or", + "q_or", + "v_or", + "a_or", + "p_ex", + "q_ex", + "v_ex", + "a_ex", + "rho", + "line_status", + "timestep_overflow", + "topo_vect", + "time_before_cooldown_line", + "time_before_cooldown_sub", + "time_next_maintenance", + "duration_next_maintenance", + "target_dispatch", + "actual_dispatch" + ] attr_list_json = [ "storage_charge", "storage_power_target", diff --git a/grid2op/simulator/simulator.py b/grid2op/simulator/simulator.py index 142097944..8f5ba6943 100644 --- a/grid2op/simulator/simulator.py +++ b/grid2op/simulator/simulator.py @@ -7,6 +7,11 @@ # This file is part of Grid2Op, Grid2Op a testbed platform to model sequential decision making in power systems. import copy from typing import Optional, Tuple +try: + from typing import Self +except ImportError: + from typing_extensions import Self + import numpy as np import os from scipy.optimize import minimize @@ -70,7 +75,7 @@ def __init__( f"inheriting from BaseEnv" ) if env.backend._can_be_copied: - self.backend = env.backend.copy() + self.backend: Backend = env.backend.copy() else: raise SimulatorError("Impossible to make a Simulator when you " "cannot copy the backend of the environment.") @@ -100,7 +105,7 @@ def converged(self) -> bool: def converged(self, values): raise SimulatorError("Cannot set this property.") - def copy(self) -> "Simulator": + def copy(self) -> Self: """Allows to perform a (deep) copy of the simulator. Returns @@ -126,7 +131,7 @@ def copy(self) -> "Simulator": res._highres_sim_counter = self._highres_sim_counter return res - def change_backend(self, backend: Backend): + def change_backend(self, backend: Backend) -> None: """You can use this function in case you want to change the "solver" use to perform the computation. For example, you could use a machine learning based model to do the computation (to accelerate them), provided @@ -311,7 +316,7 @@ def _adjust_controlable_gen( # which generators needs to be "optimized" -> the one where # the target function matter - gen_in_target = target_dispatch[self.current_obs.gen_redispatchable] != 0.0 + gen_in_target = np.abs(target_dispatch[self.current_obs.gen_redispatchable]) >= 1e-7 # compute the upper / lower bounds for the generators dispatchable = new_gen_p[self.current_obs.gen_redispatchable] @@ -398,7 +403,7 @@ def f(init): # the idea here is to chose a initial point that would be close to the # desired solution (split the (sum of the) dispatch to the available generators) x0 = 1.0 * target_dispatch_redisp - can_adjust = x0 == 0.0 + can_adjust = np.abs(x0) <= 1e-7 if (can_adjust).any(): init_sum = x0.sum() denom_adjust = (1.0 / weights[can_adjust]).sum() @@ -475,8 +480,8 @@ def _fix_redisp_curtailment_storage( target_dispatch = self.current_obs.target_dispatch + act.redispatch # if previous setpoint was say -2 and at this step I redispatch of # say + 4 then the real setpoint should be +2 (and not +4) - new_vect_redisp = (act.redispatch != 0.0) & ( - self.current_obs.target_dispatch == 0.0 + new_vect_redisp = (np.abs(act.redispatch) >= 1e-7) & ( + np.abs(self.current_obs.target_dispatch) <= 1e-7 ) target_dispatch[new_vect_redisp] += self.current_obs.actual_dispatch[ new_vect_redisp diff --git a/grid2op/tests/BaseBackendTest.py b/grid2op/tests/BaseBackendTest.py index b8f99b617..710db907e 100644 --- a/grid2op/tests/BaseBackendTest.py +++ b/grid2op/tests/BaseBackendTest.py @@ -350,7 +350,7 @@ def test_voltages_correct_load_gen(self): p_ex, q_ex, v_ex, a_ex = self.backend.lines_ex_info() for c_id, sub_id in enumerate(self.backend.load_to_subid): - l_ids = np.where(self.backend.line_or_to_subid == sub_id)[0] + l_ids = np.nonzero(self.backend.line_or_to_subid == sub_id)[0] if len(l_ids): l_id = l_ids[0] assert ( @@ -358,7 +358,7 @@ def test_voltages_correct_load_gen(self): ), "problem for load {}".format(c_id) continue - l_ids = np.where(self.backend.line_ex_to_subid == sub_id)[0] + l_ids = np.nonzero(self.backend.line_ex_to_subid == sub_id)[0] if len(l_ids): l_id = l_ids[0] assert ( @@ -368,7 +368,7 @@ def test_voltages_correct_load_gen(self): assert False, "load {} has not been checked".format(c_id) for g_id, sub_id in enumerate(self.backend.gen_to_subid): - l_ids = np.where(self.backend.line_or_to_subid == sub_id)[0] + l_ids = np.nonzero(self.backend.line_or_to_subid == sub_id)[0] if len(l_ids): l_id = l_ids[0] assert ( @@ -376,7 +376,7 @@ def test_voltages_correct_load_gen(self): ), "problem for generator {}".format(g_id) continue - l_ids = np.where(self.backend.line_ex_to_subid == sub_id)[0] + l_ids = np.nonzero(self.backend.line_ex_to_subid == sub_id)[0] if len(l_ids): l_id = l_ids[0] assert ( @@ -972,22 +972,22 @@ def test_topo_set1sub(self): assert np.max(topo_vect) == 2, "no buses have been changed" # check that the objects have been properly moved - load_ids = np.where(self.backend.load_to_subid == id_)[0] + load_ids = np.nonzero(self.backend.load_to_subid == id_)[0] assert np.all( topo_vect[self.backend.load_pos_topo_vect[load_ids]] == arr[self.backend.load_to_sub_pos[load_ids]] ) - lor_ids = np.where(self.backend.line_or_to_subid == id_)[0] + lor_ids = np.nonzero(self.backend.line_or_to_subid == id_)[0] assert np.all( topo_vect[self.backend.line_or_pos_topo_vect[lor_ids]] == arr[self.backend.line_or_to_sub_pos[lor_ids]] ) - lex_ids = np.where(self.backend.line_ex_to_subid == id_)[0] + lex_ids = np.nonzero(self.backend.line_ex_to_subid == id_)[0] assert np.all( topo_vect[self.backend.line_ex_pos_topo_vect[lex_ids]] == arr[self.backend.line_ex_to_sub_pos[lex_ids]] ) - gen_ids = np.where(self.backend.gen_to_subid == id_)[0] + gen_ids = np.nonzero(self.backend.gen_to_subid == id_)[0] assert np.all( topo_vect[self.backend.gen_pos_topo_vect[gen_ids]] == arr[self.backend.gen_to_sub_pos[gen_ids]] @@ -1071,22 +1071,22 @@ def test_topo_change1sub(self): assert np.max(topo_vect) == 2, "no buses have been changed" # check that the objects have been properly moved - load_ids = np.where(self.backend.load_to_subid == id_)[0] + load_ids = np.nonzero(self.backend.load_to_subid == id_)[0] assert np.all( topo_vect[self.backend.load_pos_topo_vect[load_ids]] == 1 + arr[self.backend.load_to_sub_pos[load_ids]] ) - lor_ids = np.where(self.backend.line_or_to_subid == id_)[0] + lor_ids = np.nonzero(self.backend.line_or_to_subid == id_)[0] assert np.all( topo_vect[self.backend.line_or_pos_topo_vect[lor_ids]] == 1 + arr[self.backend.line_or_to_sub_pos[lor_ids]] ) - lex_ids = np.where(self.backend.line_ex_to_subid == id_)[0] + lex_ids = np.nonzero(self.backend.line_ex_to_subid == id_)[0] assert np.all( topo_vect[self.backend.line_ex_pos_topo_vect[lex_ids]] == 1 + arr[self.backend.line_ex_to_sub_pos[lex_ids]] ) - gen_ids = np.where(self.backend.gen_to_subid == id_)[0] + gen_ids = np.nonzero(self.backend.gen_to_subid == id_)[0] assert np.all( topo_vect[self.backend.gen_pos_topo_vect[gen_ids]] == 1 + arr[self.backend.gen_to_sub_pos[gen_ids]] @@ -1146,22 +1146,22 @@ def test_topo_change_1sub_twice(self): assert np.max(topo_vect) == 2, "no buses have been changed" # check that the objects have been properly moved - load_ids = np.where(self.backend.load_to_subid == id_)[0] + load_ids = np.nonzero(self.backend.load_to_subid == id_)[0] assert np.all( topo_vect[self.backend.load_pos_topo_vect[load_ids]] == 1 + arr[self.backend.load_to_sub_pos[load_ids]] ) - lor_ids = np.where(self.backend.line_or_to_subid == id_)[0] + lor_ids = np.nonzero(self.backend.line_or_to_subid == id_)[0] assert np.all( topo_vect[self.backend.line_or_pos_topo_vect[lor_ids]] == 1 + arr[self.backend.line_or_to_sub_pos[lor_ids]] ) - lex_ids = np.where(self.backend.line_ex_to_subid == id_)[0] + lex_ids = np.nonzero(self.backend.line_ex_to_subid == id_)[0] assert np.all( topo_vect[self.backend.line_ex_pos_topo_vect[lex_ids]] == 1 + arr[self.backend.line_ex_to_sub_pos[lex_ids]] ) - gen_ids = np.where(self.backend.gen_to_subid == id_)[0] + gen_ids = np.nonzero(self.backend.gen_to_subid == id_)[0] assert np.all( topo_vect[self.backend.gen_pos_topo_vect[gen_ids]] == 1 + arr[self.backend.gen_to_sub_pos[gen_ids]] @@ -1236,44 +1236,44 @@ def test_topo_change_2sub(self): assert np.max(topo_vect) == 2, "no buses have been changed" # check that the objects have been properly moved - load_ids = np.where(self.backend.load_to_subid == id_1)[0] + load_ids = np.nonzero(self.backend.load_to_subid == id_1)[0] assert np.all( topo_vect[self.backend.load_pos_topo_vect[load_ids]] == 1 + arr1[self.backend.load_to_sub_pos[load_ids]] ) - lor_ids = np.where(self.backend.line_or_to_subid == id_1)[0] + lor_ids = np.nonzero(self.backend.line_or_to_subid == id_1)[0] assert np.all( topo_vect[self.backend.line_or_pos_topo_vect[lor_ids]] == 1 + arr1[self.backend.line_or_to_sub_pos[lor_ids]] ) - lex_ids = np.where(self.backend.line_ex_to_subid == id_1)[0] + lex_ids = np.nonzero(self.backend.line_ex_to_subid == id_1)[0] assert np.all( topo_vect[self.backend.line_ex_pos_topo_vect[lex_ids]] == 1 + arr1[self.backend.line_ex_to_sub_pos[lex_ids]] ) - gen_ids = np.where(self.backend.gen_to_subid == id_1)[0] + gen_ids = np.nonzero(self.backend.gen_to_subid == id_1)[0] assert np.all( topo_vect[self.backend.gen_pos_topo_vect[gen_ids]] == 1 + arr1[self.backend.gen_to_sub_pos[gen_ids]] ) - load_ids = np.where(self.backend.load_to_subid == id_2)[0] + load_ids = np.nonzero(self.backend.load_to_subid == id_2)[0] # TODO check the topology symmetry assert np.all( topo_vect[self.backend.load_pos_topo_vect[load_ids]] == arr2[self.backend.load_to_sub_pos[load_ids]] ) - lor_ids = np.where(self.backend.line_or_to_subid == id_2)[0] + lor_ids = np.nonzero(self.backend.line_or_to_subid == id_2)[0] assert np.all( topo_vect[self.backend.line_or_pos_topo_vect[lor_ids]] == arr2[self.backend.line_or_to_sub_pos[lor_ids]] ) - lex_ids = np.where(self.backend.line_ex_to_subid == id_2)[0] + lex_ids = np.nonzero(self.backend.line_ex_to_subid == id_2)[0] assert np.all( topo_vect[self.backend.line_ex_pos_topo_vect[lex_ids]] == arr2[self.backend.line_ex_to_sub_pos[lex_ids]] ) - gen_ids = np.where(self.backend.gen_to_subid == id_2)[0] + gen_ids = np.nonzero(self.backend.gen_to_subid == id_2)[0] assert np.all( topo_vect[self.backend.gen_pos_topo_vect[gen_ids]] == arr2[self.backend.gen_to_sub_pos[gen_ids]] diff --git a/grid2op/tests/BaseRedispTest.py b/grid2op/tests/BaseRedispTest.py index b6a4b6567..3fe3ea4e6 100644 --- a/grid2op/tests/BaseRedispTest.py +++ b/grid2op/tests/BaseRedispTest.py @@ -803,7 +803,7 @@ def test_dispatch_still_not_zero(self): assert np.all( obs.prod_p[0:2] <= obs.gen_pmax[0:2] ), "above pmax for ts {}".format(i) - except: + except Exception as exc_: pass assert np.all( obs.prod_p[0:2] >= -obs.gen_pmin[0:2] diff --git a/grid2op/tests/_aux_test_gym_compat.py b/grid2op/tests/_aux_test_gym_compat.py index 099e99ad2..e9c697ad2 100644 --- a/grid2op/tests/_aux_test_gym_compat.py +++ b/grid2op/tests/_aux_test_gym_compat.py @@ -1870,8 +1870,41 @@ def test_supported_keys_discrete(self): raise RuntimeError( f"Some property of the actions are not modified for attr {attr_nm}" ) - - + + def test_discrete_multidiscrete_set(self): + """test that discrete with only set_bus has the same number of actions as mmultidiscrete with one_sub_set""" + with warnings.catch_warnings(): + warnings.filterwarnings("ignore") + self.env_gym.action_space = self._aux_DiscreteActSpace_cls()( + self.env.action_space, attr_to_keep=["set_bus"] + ) + n_disc = 1 * self.env_gym.action_space.n + with warnings.catch_warnings(): + warnings.filterwarnings("ignore") + self.env_gym.action_space = self._aux_MultiDiscreteActSpace_cls()( + self.env.action_space, attr_to_keep=["one_sub_set"] + ) + n_multidisc = 1 * self.env_gym.action_space.nvec[0] + assert n_disc == n_multidisc, f"discrepency between discrete[set_bus] (size : {n_disc}) and multidisc[one_sub_set] (size {n_multidisc})" + + + def test_discrete_multidiscrete_change(self): + """test that discrete with only change_bus has the same number of actions as mmultidiscrete with one_sub_change""" + with warnings.catch_warnings(): + warnings.filterwarnings("ignore") + self.env_gym.action_space = self._aux_DiscreteActSpace_cls()( + self.env.action_space, attr_to_keep=["change_bus"] + ) + n_disc = 1 * self.env_gym.action_space.n + with warnings.catch_warnings(): + warnings.filterwarnings("ignore") + self.env_gym.action_space = self._aux_MultiDiscreteActSpace_cls()( + self.env.action_space, attr_to_keep=["one_sub_change"] + ) + n_multidisc = 1 * self.env_gym.action_space.nvec[0] + assert n_disc == n_multidisc, f"discrepency between discrete[change_bus] (size : {n_disc}) and multidisc[one_sub_change] (size {n_multidisc})" + + class _AuxTestGOObsInRange: def setUp(self) -> None: self._skip_if_no_gym() diff --git a/grid2op/tests/aaa_test_backend_interface.py b/grid2op/tests/aaa_test_backend_interface.py index 9abf19761..8f01f0b60 100644 --- a/grid2op/tests/aaa_test_backend_interface.py +++ b/grid2op/tests/aaa_test_backend_interface.py @@ -38,9 +38,9 @@ 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) -> Backend: + def aux_make_backend(self, n_busbar=2) -> Backend: """do not run nor modify ! (used for this test class only)""" - backend = self.make_backend_with_glue_code() + backend = self.make_backend_with_glue_code(n_busbar=n_busbar) 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()) @@ -594,7 +594,7 @@ def _aux_check_topo_vect(self, backend : Backend): assert len(topo_vect) == dim_topo, (f"backend.get_topo_vect() should return a vector of size 'dim_topo' " f"({dim_topo}) but found size is {len(topo_vect)}. " f"Remember: shunt are not part of the topo_vect") - assert np.all(topo_vect <= 2), (f"For simple environment, we suppose there are 2 buses per substation / voltage levels. " + assert np.all(topo_vect <= type(backend).n_busbar_per_sub), (f"For simple environment, we suppose there are 2 buses per substation / voltage levels. " f"topo_vect is supposed to give the id of the busbar (in the substation) to " f"which the element is connected. This cannot be {np.max(topo_vect)}." f"NB: this test is expected to fail if you test on a grid where more " @@ -1381,9 +1381,9 @@ def test_27_topo_vect_disconnect(self): def _aux_aux_get_line(self, el_id, el_to_subid, line_xx_to_subid): sub_id = el_to_subid[el_id] if (line_xx_to_subid == sub_id).sum() >= 2: - return True, np.where(line_xx_to_subid == sub_id)[0][0] + return True, np.nonzero(line_xx_to_subid == sub_id)[0][0] elif (line_xx_to_subid == sub_id).sum() == 1: - return False, np.where(line_xx_to_subid == sub_id)[0][0] + return False, np.nonzero(line_xx_to_subid == sub_id)[0][0] else: return None @@ -1555,4 +1555,118 @@ def test_28_topo_vect_set(self): el_nm, el_key, el_pos_topo_vect) else: warnings.warn(f"{type(self).__name__} test_28_topo_vect_set: This test is not performed in depth as your backend does not support storage units (or there are none on the grid)") - \ No newline at end of file + + def test_29_xxx_handle_more_than_2_busbar_called(self): + """Tests that at least one of the function: + + - :func:`grid2op.Backend.Backend.can_handle_more_than_2_busbar` + - :func:`grid2op.Backend.Backend.cannot_handle_more_than_2_busbar` + + has been implemented in the :func:`grid2op.Backend.Backend.load_grid` + implementation. + + This test supposes that : + + - backend.load_grid(...) is implemented + + .. versionadded:: 1.10.0 + + """ + self.skip_if_needed() + backend = self.aux_make_backend() + assert not backend._missing_two_busbars_support_info + + def test_30_n_busbar_per_sub_ok(self): + """Tests that your backend can properly handle more than + 3 busbars (only applies if your backend supports the feature): basically that + objects can be moved to busbar 3 without trouble. + + This test supposes that : + + - backend.load_grid(...) is implemented + - backend.runpf() (AC mode) is implemented + - backend.apply_action() for all types of action + - backend.reset() is implemented + - backend.get_topo_vect() is implemented + + .. versionadded:: 1.10.0 + + """ + self.skip_if_needed() + backend = self.aux_make_backend(n_busbar=3) + cls = type(backend) + if cls.n_busbar_per_sub != 3: + self.skipTest("Your backend does not support more than 2 busbars.") + + res = backend.runpf(is_dc=False) + assert res[0], f"Your backend diverged in AC after loading the grid state, error was {res[1]}" + topo_vect_orig = self._aux_check_topo_vect(backend) + + # line or + line_id = 0 + busbar_id = 3 + backend.reset(self.get_path(), self.get_casefile()) + action = type(backend)._complete_action_class() + action.update({"set_bus": {"lines_or_id": [(line_id, busbar_id)]}}) + bk_act = type(backend).my_bk_act_class() + bk_act += action + backend.apply_action(bk_act) + res = backend.runpf(is_dc=False) + assert res[0], f"Your backend diverged in AC after setting a line (or side) on busbar 3, error was {res[1]}" + topo_vect = self._aux_check_topo_vect(backend) + error_msg = (f"Line {line_id} (or. side) has been moved to busbar {busbar_id}, yet according to 'topo_vect' " + f"is still connected (origin side) to busbar {topo_vect[cls.line_or_pos_topo_vect[line_id]]}") + assert topo_vect[cls.line_or_pos_topo_vect[line_id]] == busbar_id, error_msg + + # line ex + line_id = 0 + busbar_id = 3 + backend.reset(self.get_path(), self.get_casefile()) + action = type(backend)._complete_action_class() + action.update({"set_bus": {"lines_ex_id": [(line_id, busbar_id)]}}) + bk_act = type(backend).my_bk_act_class() + bk_act += action + backend.apply_action(bk_act) + res = backend.runpf(is_dc=False) + assert res[0], f"Your backend diverged in AC after setting a line (ex side) on busbar 3, error was {res[1]}" + topo_vect = self._aux_check_topo_vect(backend) + error_msg = (f"Line {line_id} (ex. side) has been moved to busbar {busbar_id}, yet according to 'topo_vect' " + f"is still connected (ext side) to busbar {topo_vect[cls.line_ex_pos_topo_vect[line_id]]}") + assert topo_vect[cls.line_ex_pos_topo_vect[line_id]] == busbar_id, error_msg + + # load + backend.reset(self.get_path(), self.get_casefile()) + busbar_id = 3 + nb_el = cls.n_load + el_to_subid = cls.load_to_subid + el_nm = "load" + el_key = "loads_id" + el_pos_topo_vect = cls.load_pos_topo_vect + self._aux_check_el_generic(backend, busbar_id, nb_el, el_to_subid, + el_nm, el_key, el_pos_topo_vect) + + # generator + backend.reset(self.get_path(), self.get_casefile()) + busbar_id = 3 + nb_el = cls.n_gen + el_to_subid = cls.gen_to_subid + el_nm = "generator" + el_key = "generators_id" + el_pos_topo_vect = cls.gen_pos_topo_vect + self._aux_check_el_generic(backend, busbar_id, nb_el, el_to_subid, + el_nm, el_key, el_pos_topo_vect) + + # storage + if cls.n_storage > 0: + backend.reset(self.get_path(), self.get_casefile()) + busbar_id = 3 + nb_el = cls.n_storage + el_to_subid = cls.storage_to_subid + el_nm = "storage" + el_key = "storages_id" + el_pos_topo_vect = cls.storage_pos_topo_vect + self._aux_check_el_generic(backend, busbar_id, nb_el, el_to_subid, + 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)") + \ No newline at end of file diff --git a/grid2op/tests/helper_path_test.py b/grid2op/tests/helper_path_test.py index 59bf81ed2..e9f5efc3d 100644 --- a/grid2op/tests/helper_path_test.py +++ b/grid2op/tests/helper_path_test.py @@ -67,11 +67,12 @@ 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="") -> Backend: + def make_backend_with_glue_code(self, detailed_infos_for_cascading_failures=False, extra_name="", n_busbar=2) -> 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) return bk def get_path(self) -> str: diff --git a/grid2op/tests/test_Action.py b/grid2op/tests/test_Action.py index 5de72f7b9..b45a810a9 100644 --- a/grid2op/tests/test_Action.py +++ b/grid2op/tests/test_Action.py @@ -29,6 +29,7 @@ def _get_action_grid_class(): GridObjects.env_name = "test_action_env" + GridObjects.n_busbar_per_sub = 2 GridObjects.n_gen = 5 GridObjects.name_gen = np.array(["gen_{}".format(i) for i in range(5)]) GridObjects.n_load = 11 @@ -104,6 +105,7 @@ def _get_action_grid_class(): json_ = { "glop_version": grid2op.__version__, + "n_busbar_per_sub": "2", "name_gen": ["gen_0", "gen_1", "gen_2", "gen_3", "gen_4"], "name_load": [ "load_0", @@ -872,7 +874,7 @@ def test_to_vect(self): tmp[-action.n_gen :] = -1 # compute the "set_bus" vect - id_set = np.where(np.array(type(action).attr_list_vect) == "_set_topo_vect")[0][0] + id_set = np.nonzero(np.array(type(action).attr_list_vect) == "_set_topo_vect")[0][0] size_before = 0 for el in type(action).attr_list_vect[:id_set]: arr_ = action._get_array_from_attr_name(el) @@ -939,7 +941,7 @@ def test_to_vect(self): 0, ] ) - id_change = np.where(np.array(type(action).attr_list_vect) == "_change_bus_vect")[0][ + id_change = np.nonzero(np.array(type(action).attr_list_vect) == "_change_bus_vect")[0][ 0 ] size_before = 0 diff --git a/grid2op/tests/test_Agent.py b/grid2op/tests/test_Agent.py index 30195af39..007a0fbb7 100644 --- a/grid2op/tests/test_Agent.py +++ b/grid2op/tests/test_Agent.py @@ -142,6 +142,9 @@ def test_2_busswitch(self): expected_reward = dt_float(12075.389) expected_reward = dt_float(12277.632) expected_reward = dt_float(12076.35644531 / 12.) + # 1006.363037109375 + #: Breaking change in 1.10.0: topology are not in the same order + expected_reward = dt_float(1006.34924) assert ( np.abs(cum_reward - expected_reward) <= self.tol_one ), f"The reward has not been properly computed {cum_reward} instead of {expected_reward}" diff --git a/grid2op/tests/test_GridObjects.py b/grid2op/tests/test_GridObjects.py index a5ee0a493..5de75ab8b 100644 --- a/grid2op/tests/test_GridObjects.py +++ b/grid2op/tests/test_GridObjects.py @@ -152,7 +152,45 @@ def test_auxilliary_func(self): ) # this should pass bk_cls.assert_grid_correct_cls() - + + def test_topo_vect_element(self): + """ + .. newinversion:: 1.10.0 + Test this utilitary function + """ + with warnings.catch_warnings(): + warnings.filterwarnings("ignore") + env = grid2op.make( + "educ_case14_storage", + test=True, + _add_to_name=type(self).__name__+"test_gridobjects_testauxfunctions", + ) + cls = type(env) + for el_id, el_pos_topo_vect in enumerate(cls.load_pos_topo_vect): + res = cls.topo_vect_element(el_pos_topo_vect) + assert "load_id" in res + assert res["load_id"] == el_id + for el_id, el_pos_topo_vect in enumerate(cls.gen_pos_topo_vect): + res = cls.topo_vect_element(el_pos_topo_vect) + assert "gen_id" in res + assert res["gen_id"] == el_id + for el_id, el_pos_topo_vect in enumerate(cls.storage_pos_topo_vect): + res = cls.topo_vect_element(el_pos_topo_vect) + assert "storage_id" in res + assert res["storage_id"] == el_id + for el_id, el_pos_topo_vect in enumerate(cls.line_or_pos_topo_vect): + res = cls.topo_vect_element(el_pos_topo_vect) + assert "line_id" in res + assert res["line_id"] == {"or": el_id} + assert "line_or_id" in res + assert res["line_or_id"] == el_id + for el_id, el_pos_topo_vect in enumerate(cls.line_ex_pos_topo_vect): + res = cls.topo_vect_element(el_pos_topo_vect) + assert "line_id" in res + assert res["line_id"] == {"ex": el_id} + assert "line_ex_id" in res + assert res["line_ex_id"] == el_id + if __name__ == "__main__": unittest.main() diff --git a/grid2op/tests/test_Observation.py b/grid2op/tests/test_Observation.py index 7019c87fc..1742ae4e3 100644 --- a/grid2op/tests/test_Observation.py +++ b/grid2op/tests/test_Observation.py @@ -52,6 +52,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", "name_load": [ "load_1_0", "load_2_1", @@ -1785,7 +1786,7 @@ def aux_test_conn_mat2(self, as_csr=False): obs, reward, done, info = self.env.step( self.env.action_space({"set_bus": {"lines_or_id": [(13, 2), (14, 2)]}}) ) - assert not done + assert not done, f"failed with error {info['exception']}" assert obs.bus_connectivity_matrix(as_csr).shape == (15, 15) assert ( obs.bus_connectivity_matrix(as_csr)[14, 11] == 1.0 diff --git a/grid2op/tests/test_gym_env_renderer.py b/grid2op/tests/test_gym_env_renderer.py index 4b26d89be..7a7a68fc4 100644 --- a/grid2op/tests/test_gym_env_renderer.py +++ b/grid2op/tests/test_gym_env_renderer.py @@ -12,7 +12,6 @@ import grid2op from grid2op.gym_compat import GymEnv -import numpy as np class TestGymEnvRenderer(unittest.TestCase): diff --git a/grid2op/tests/test_gymnasium_compat.py b/grid2op/tests/test_gymnasium_compat.py index c7417e26b..dd06153b3 100644 --- a/grid2op/tests/test_gymnasium_compat.py +++ b/grid2op/tests/test_gymnasium_compat.py @@ -93,7 +93,12 @@ class TestMultiDiscreteGymnasiumActSpace(_AuxTestMultiDiscreteGymActSpace, Auxil pass class TestDiscreteGymnasiumActSpace(_AuxTestDiscreteGymActSpace, AuxilliaryForTestGymnasium, unittest.TestCase): - pass + def test_class_different_from_multi_discrete(self): + from grid2op.gym_compat import (DiscreteActSpaceGymnasium, + MultiDiscreteActSpaceGymnasium) + assert DiscreteActSpaceGymnasium is not MultiDiscreteActSpaceGymnasium + assert DiscreteActSpaceGymnasium.__doc__ != MultiDiscreteActSpaceGymnasium.__doc__ + assert DiscreteActSpaceGymnasium.__name__ != MultiDiscreteActSpaceGymnasium.__name__ class TestAllGymnasiumActSpaceWithAlarm(_AuxTestAllGymActSpaceWithAlarm, AuxilliaryForTestGymnasium, unittest.TestCase): pass diff --git a/grid2op/tests/test_multidiscrete_act_space.py b/grid2op/tests/test_multidiscrete_act_space.py new file mode 100644 index 000000000..760c7ac91 --- /dev/null +++ b/grid2op/tests/test_multidiscrete_act_space.py @@ -0,0 +1,163 @@ +# Copyright (c) 2024, RTE (https://www.rte-france.com) +# See AUTHORS.txt +# This Source Code Form is subject to the terms of the Mozilla Public License, version 2.0. +# If a copy of the Mozilla Public License, version 2.0 was not distributed with this file, +# you can obtain one at http://mozilla.org/MPL/2.0/. +# SPDX-License-Identifier: MPL-2.0 +# This file is part of Grid2Op, Grid2Op a testbed platform to model sequential decision making in power systems. +import unittest +import warnings +import numpy as np + +import grid2op +from grid2op.Backend import PandaPowerBackend +from grid2op.Action import CompleteAction +from grid2op.gym_compat import MultiDiscreteActSpace, GymEnv + + +class TestMultiDiscreteActSpaceOneLineChangeSet(unittest.TestCase): + def get_env_nm(self): + return "educ_case14_storage" + + def get_reset_kwargs(self) -> dict: + # seed has been tuned for the tests to pass + return dict(seed=self.seed, options={"time serie id": 0}) + + def setUp(self) -> None: + with warnings.catch_warnings(): + warnings.filterwarnings("ignore") + self.env = grid2op.make(self.get_env_nm(), + backend=PandaPowerBackend(), + action_class=CompleteAction, + test=True, + _add_to_name=type(self).__name__) + self.seed = 0 + self.gym_env = GymEnv(self.env) + + def tearDown(self) -> None: + self.env.close() + self.gym_env.close() + return super().tearDown() + + def test_kwargs_ok(self): + with warnings.catch_warnings(): + warnings.filterwarnings("error") + act_space = MultiDiscreteActSpace(self.env.action_space, attr_to_keep=["one_line_set"]) + assert act_space.nvec[0] == 1 + 2 * type(self.env).n_line + with warnings.catch_warnings(): + warnings.filterwarnings("error") + act_space = MultiDiscreteActSpace(self.env.action_space, attr_to_keep=["one_line_change"]) + assert act_space.nvec[0] == 1 + type(self.env).n_line + + def _aux_assert_flags(self, glop_act): + assert not glop_act._modif_alarm + assert not glop_act._modif_alert + assert not glop_act._modif_curtailment + assert not glop_act._modif_storage + assert not glop_act._modif_redispatch + assert not glop_act._modif_set_bus + assert not glop_act._modif_change_bus + + def test_action_ok_set(self): + act_space = MultiDiscreteActSpace(self.env.action_space, attr_to_keep=["one_line_set"]) + act_space.seed(self.seed) + for _ in range(10): + act = act_space.sample() + glop_act = act_space.from_gym(act) + self._aux_assert_flags(glop_act) + assert not glop_act._modif_change_status + lines_, subs_ = glop_act.get_topological_impact() + assert (~subs_).all() + if act[0] >= 1: # 0 is for do nothing + # 1 is connect line 0, 2 is disconnect line 0 + # 3 is connect line 1, etc. + assert glop_act._modif_set_status + assert lines_[(act[0]- 1) // 2 ] + else: + assert not glop_act._modif_set_status + assert (~lines_).all() + + glop_act = act_space.from_gym(np.array([0])) + lines_, subs_ = glop_act.get_topological_impact() + assert (~subs_).all() + assert (~lines_).all() + self._aux_assert_flags(glop_act) + assert not glop_act._modif_change_status + assert not glop_act._modif_set_status + + for i in range(1, 2 * type(self.env).n_line + 1): + glop_act = act_space.from_gym(np.array([i])) + lines_, subs_ = glop_act.get_topological_impact() + assert (~subs_).all() + self._aux_assert_flags(glop_act) + assert not glop_act._modif_change_status + assert glop_act._modif_set_status + l_id = (i- 1) // 2 + assert lines_[l_id] + assert glop_act._set_line_status[l_id] == ((i-1) % 2 == 0) * 2 - 1, f"error for {i}" + + def test_action_ok_change(self): + act_space = MultiDiscreteActSpace(self.env.action_space, attr_to_keep=["one_line_change"]) + act_space.seed(self.seed) + for _ in range(10): + act = act_space.sample() + glop_act = act_space.from_gym(act) + self._aux_assert_flags(glop_act) + assert not glop_act._modif_set_status + lines_, subs_ = glop_act.get_topological_impact() + assert (~subs_).all() + if act[0] >= 1: # 0 is for do nothing + assert glop_act._modif_change_status + assert lines_[(act[0]- 1)] + else: + assert (~lines_).all() + assert not glop_act._modif_change_status + + glop_act = act_space.from_gym(np.array([0])) + lines_, subs_ = glop_act.get_topological_impact() + assert (~subs_).all() + assert (~lines_).all() + self._aux_assert_flags(glop_act) + assert not glop_act._modif_change_status + assert not glop_act._modif_set_status + + for i in range(1, type(self.env).n_line + 1): + glop_act = act_space.from_gym(np.array([i])) + lines_, subs_ = glop_act.get_topological_impact() + assert (~subs_).all() + self._aux_assert_flags(glop_act) + assert glop_act._modif_change_status + assert not glop_act._modif_set_status + l_id = (i- 1) + assert lines_[l_id] + assert glop_act._switch_line_status[l_id], f"error for {i}" + + def test_can_combine_topo_line_set(self): + act_space = MultiDiscreteActSpace(self.env.action_space, + attr_to_keep=["one_line_set", "one_sub_set"]) + act_space.seed(self.seed) + for _ in range(10): + act = act_space.sample() + glop_act = act_space.from_gym(act) + lines_, subs_ = glop_act.get_topological_impact() + if act[0]: + assert lines_.sum() == 1 + if act[1]: + assert subs_.sum() == 1 + + def test_can_combine_topo_line_change(self): + act_space = MultiDiscreteActSpace(self.env.action_space, + attr_to_keep=["one_line_change", "one_sub_change"]) + act_space.seed(self.seed) + for _ in range(10): + act = act_space.sample() + glop_act = act_space.from_gym(act) + lines_, subs_ = glop_act.get_topological_impact() + if act[0]: + assert lines_.sum() == 1 + if act[1]: + assert subs_.sum() == 1 + + +if __name__ == "__main__": + unittest.main() diff --git a/grid2op/tests/test_n_busbar_per_sub.py b/grid2op/tests/test_n_busbar_per_sub.py new file mode 100644 index 000000000..b1bed8dbd --- /dev/null +++ b/grid2op/tests/test_n_busbar_per_sub.py @@ -0,0 +1,2015 @@ +# Copyright (c) 2024, RTE (https://www.rte-france.com) +# See AUTHORS.txt +# This Source Code Form is subject to the terms of the Mozilla Public License, version 2.0. +# If a copy of the Mozilla Public License, version 2.0 was not distributed with this file, +# you can obtain one at http://mozilla.org/MPL/2.0/. +# SPDX-License-Identifier: MPL-2.0 +# This file is part of Grid2Op, Grid2Op a testbed platform to model sequential decision making in power systems. + +import warnings +import unittest +from grid2op.tests.helper_path_test import * + +import grid2op +from grid2op.Agent import BaseAgent +from grid2op.Environment import MaskedEnvironment, TimedOutEnvironment +from grid2op.Runner import Runner +from grid2op.Backend import PandaPowerBackend +from grid2op.Space import DEFAULT_N_BUSBAR_PER_SUB +from grid2op.Action import ActionSpace, BaseAction, CompleteAction +from grid2op.Observation import BaseObservation +from grid2op.Exceptions import Grid2OpException, EnvError, IllegalAction +from grid2op.gym_compat import GymEnv, DiscreteActSpace, BoxGymActSpace, BoxGymObsSpace, MultiDiscreteActSpace +import pdb + + +# test on a big computer only with lots of RAM, and lots of time available... +HAS_TIME_AND_MEMORY = False + + +class _AuxFakeBackendSupport(PandaPowerBackend): + def cannot_handle_more_than_2_busbar(self): + """dont do it at home !""" + return self.can_handle_more_than_2_busbar() + + +class _AuxFakeBackendNoSupport(PandaPowerBackend): + def can_handle_more_than_2_busbar(self): + """dont do it at home !""" + return self.cannot_handle_more_than_2_busbar() + + +class _AuxFakeBackendNoCalled(PandaPowerBackend): + def can_handle_more_than_2_busbar(self): + """dont do it at home !""" + pass + def cannot_handle_more_than_2_busbar(self): + """dont do it at home !""" + pass + + +class TestRightNumberNbBus(unittest.TestCase): + """This test that, when changing n_busbar in make it is + back propagated where it needs in the class attribute (this includes + testing that the observation_space, action_space, runner, environment etc. + are all 'informed' about this feature) + + This class also tests than when the implementation of the backend does not + use the new `can_handle_more_than_2_busbar` or `cannot_handle_more_than_2_busbar` + then the legacy behaviour is used (only 2 busbar per substation even if the + user asked for a different number) + """ + def _aux_fun_test(self, env, n_busbar): + assert type(env).n_busbar_per_sub == n_busbar, f"type(env).n_busbar_per_sub = {type(env).n_busbar_per_sub} != {n_busbar}" + assert type(env.backend).n_busbar_per_sub == n_busbar, f"env.backend).n_busbar_per_sub = {type(env.backend).n_busbar_per_sub} != {n_busbar}" + assert type(env.action_space).n_busbar_per_sub == n_busbar, f"type(env.action_space).n_busbar_per_sub = {type(env.action_space).n_busbar_per_sub} != {n_busbar}" + assert type(env.observation_space).n_busbar_per_sub == n_busbar, f"type(env.observation_space).n_busbar_per_sub = {type(env.observation_space).n_busbar_per_sub} != {n_busbar}" + obs = env.reset(seed=0, options={"time serie id": 0}) + assert type(obs).n_busbar_per_sub == n_busbar, f"type(obs).n_busbar_per_sub = {type(obs).n_busbar_per_sub} != {n_busbar}" + act = env.action_space() + assert type(act).n_busbar_per_sub == n_busbar, f"type(act).n_busbar_per_sub = {type(act).n_busbar_per_sub} != {n_busbar}" + + def test_fail_if_not_int(self): + with self.assertRaises(Grid2OpException): + env = grid2op.make("l2rpn_case14_sandbox", backend=_AuxFakeBackendSupport(), test=True, n_busbar="froiy", _add_to_name=type(self).__name__+"_wrong_str") + with self.assertRaises(Grid2OpException): + env = grid2op.make("l2rpn_case14_sandbox", backend=_AuxFakeBackendSupport(), test=True, n_busbar=3.5, _add_to_name=type(self).__name__+"_wrong_float") + + def test_regular_env(self): + with warnings.catch_warnings(): + warnings.filterwarnings("ignore") + env = grid2op.make("l2rpn_case14_sandbox", backend=_AuxFakeBackendSupport(), test=True, _add_to_name=type(self).__name__+"_2") + self._aux_fun_test(env, DEFAULT_N_BUSBAR_PER_SUB) + + with warnings.catch_warnings(): + warnings.filterwarnings("ignore") + env = grid2op.make("l2rpn_case14_sandbox", backend=_AuxFakeBackendSupport(), test=True, n_busbar=3, _add_to_name=type(self).__name__+"_3") + self._aux_fun_test(env, 3) + + with warnings.catch_warnings(): + warnings.filterwarnings("ignore") + env = grid2op.make("l2rpn_case14_sandbox", backend=_AuxFakeBackendSupport(), test=True, n_busbar=1, _add_to_name=type(self).__name__+"_3") + self._aux_fun_test(env, 1) + + def test_multimix_env(self): + with warnings.catch_warnings(): + warnings.filterwarnings("ignore") + env = grid2op.make("l2rpn_neurips_2020_track2", backend=_AuxFakeBackendSupport(), test=True, _add_to_name=type(self).__name__+"_2") + self._aux_fun_test(env, DEFAULT_N_BUSBAR_PER_SUB) + + with warnings.catch_warnings(): + warnings.filterwarnings("ignore") + env = grid2op.make("l2rpn_neurips_2020_track2", backend=_AuxFakeBackendSupport(), test=True, n_busbar=3, _add_to_name=type(self).__name__+"_3") + self._aux_fun_test(env, 3) + + with warnings.catch_warnings(): + warnings.filterwarnings("ignore") + env = grid2op.make("l2rpn_neurips_2020_track2", backend=_AuxFakeBackendSupport(), test=True, n_busbar=1, _add_to_name=type(self).__name__+"_3") + self._aux_fun_test(env, 1) + + def test_masked_env(self): + with warnings.catch_warnings(): + warnings.filterwarnings("ignore") + env = MaskedEnvironment(grid2op.make("l2rpn_case14_sandbox", backend=_AuxFakeBackendSupport(), test=True, _add_to_name=type(self).__name__+"_mask_2"), + lines_of_interest=np.ones(shape=20, dtype=bool)) + self._aux_fun_test(env, DEFAULT_N_BUSBAR_PER_SUB) + + with warnings.catch_warnings(): + warnings.filterwarnings("ignore") + env = MaskedEnvironment(grid2op.make("l2rpn_case14_sandbox", backend=_AuxFakeBackendSupport(), test=True, n_busbar=3, _add_to_name=type(self).__name__+"_mask_3"), + lines_of_interest=np.ones(shape=20, dtype=bool)) + self._aux_fun_test(env, 3) + + with warnings.catch_warnings(): + warnings.filterwarnings("ignore") + env = MaskedEnvironment(grid2op.make("l2rpn_case14_sandbox", backend=_AuxFakeBackendSupport(), test=True, n_busbar=1, _add_to_name=type(self).__name__+"_mask_1"), + lines_of_interest=np.ones(shape=20, dtype=bool)) + self._aux_fun_test(env, 1) + + def test_to_env(self): + with warnings.catch_warnings(): + warnings.filterwarnings("ignore") + env = TimedOutEnvironment(grid2op.make("l2rpn_case14_sandbox", backend=_AuxFakeBackendSupport(), test=True, _add_to_name=type(self).__name__+"_to_2"), + time_out_ms=3000) + self._aux_fun_test(env, DEFAULT_N_BUSBAR_PER_SUB) + + with warnings.catch_warnings(): + warnings.filterwarnings("ignore") + env = TimedOutEnvironment(grid2op.make("l2rpn_case14_sandbox", backend=_AuxFakeBackendSupport(), test=True, n_busbar=3, _add_to_name=type(self).__name__+"_to_3"), + time_out_ms=3000) + self._aux_fun_test(env, 3) + + with warnings.catch_warnings(): + warnings.filterwarnings("ignore") + env = TimedOutEnvironment(grid2op.make("l2rpn_case14_sandbox", backend=_AuxFakeBackendSupport(), test=True, n_busbar=1, _add_to_name=type(self).__name__+"_to_1"), + time_out_ms=3000) + self._aux_fun_test(env, 1) + + def test_xxxhandle_more_than_2_busbar_not_called(self): + """when using a backend that did not called the `can_handle_more_than_2_busbar_not_called` + nor the `cannot_handle_more_than_2_busbar_not_called` then it's equivalent + to not support this new feature.""" + with warnings.catch_warnings(): + warnings.filterwarnings("ignore") + env = grid2op.make("l2rpn_case14_sandbox", backend=_AuxFakeBackendNoCalled(), test=True, _add_to_name=type(self).__name__+"_nocall_2") + self._aux_fun_test(env, DEFAULT_N_BUSBAR_PER_SUB) + + with warnings.catch_warnings(): + warnings.filterwarnings("ignore") + env = grid2op.make("l2rpn_case14_sandbox", backend=_AuxFakeBackendNoCalled(), test=True, n_busbar=3, _add_to_name=type(self).__name__+"_nocall_3") + self._aux_fun_test(env, DEFAULT_N_BUSBAR_PER_SUB) + + with warnings.catch_warnings(): + warnings.filterwarnings("ignore") + env = grid2op.make("l2rpn_case14_sandbox", backend=_AuxFakeBackendNoCalled(), test=True, n_busbar=1, _add_to_name=type(self).__name__+"_nocall_1") + self._aux_fun_test(env, DEFAULT_N_BUSBAR_PER_SUB) + + def test_cannot_handle_more_than_2_busbar_not_called(self): + """when using a backend that called `cannot_handle_more_than_2_busbar_not_called` then it's equivalent + to not support this new feature.""" + with warnings.catch_warnings(): + warnings.filterwarnings("ignore") + env = grid2op.make("l2rpn_case14_sandbox", backend=_AuxFakeBackendNoSupport(), test=True, _add_to_name=type(self).__name__+"_dontcalled_2") + self._aux_fun_test(env, DEFAULT_N_BUSBAR_PER_SUB) + + with warnings.catch_warnings(): + warnings.filterwarnings("ignore") + env = grid2op.make("l2rpn_case14_sandbox", backend=_AuxFakeBackendNoSupport(), test=True, n_busbar=3, _add_to_name=type(self).__name__+"_dontcalled_3") + self._aux_fun_test(env, DEFAULT_N_BUSBAR_PER_SUB) + + with warnings.catch_warnings(): + warnings.filterwarnings("ignore") + env = grid2op.make("l2rpn_case14_sandbox", backend=_AuxFakeBackendNoSupport(), test=True, n_busbar=1, _add_to_name=type(self).__name__+"_dontcalled_1") + self._aux_fun_test(env, DEFAULT_N_BUSBAR_PER_SUB) + + def test_env_copy(self): + """test env copy does work correctly""" + with warnings.catch_warnings(): + warnings.filterwarnings("ignore") + env = grid2op.make("l2rpn_case14_sandbox", backend=_AuxFakeBackendSupport(), test=True, _add_to_name=type(self).__name__+"_copy_2") + self._aux_fun_test(env, DEFAULT_N_BUSBAR_PER_SUB) + env_cpy = env.copy() + self._aux_fun_test(env_cpy, DEFAULT_N_BUSBAR_PER_SUB) + + with warnings.catch_warnings(): + warnings.filterwarnings("ignore") + env = grid2op.make("l2rpn_case14_sandbox", backend=_AuxFakeBackendSupport(), test=True, n_busbar=3, _add_to_name=type(self).__name__+"_copy_3") + self._aux_fun_test(env, 3) + env_cpy = env.copy() + self._aux_fun_test(env_cpy, 3) + + with warnings.catch_warnings(): + warnings.filterwarnings("ignore") + env = grid2op.make("l2rpn_case14_sandbox", backend=_AuxFakeBackendSupport(), test=True, n_busbar=1, _add_to_name=type(self).__name__+"_copy_1") + self._aux_fun_test(env, 1) + env_cpy = env.copy() + self._aux_fun_test(env_cpy, 1) + + def test_two_env_same_name(self): + """test i can load 2 env with the same name but different n_busbar""" + with warnings.catch_warnings(): + warnings.filterwarnings("ignore") + env_2 = grid2op.make("l2rpn_case14_sandbox", backend=_AuxFakeBackendSupport(), test=True, _add_to_name=type(self).__name__+"_same_name") + self._aux_fun_test(env_2, DEFAULT_N_BUSBAR_PER_SUB) + + with warnings.catch_warnings(): + warnings.filterwarnings("ignore") + env_3 = grid2op.make("l2rpn_case14_sandbox", backend=_AuxFakeBackendSupport(), test=True, n_busbar=3, _add_to_name=type(self).__name__+"_same_name") + self._aux_fun_test(env_3, 3) # check env_3 has indeed 3 buses + self._aux_fun_test(env_2, DEFAULT_N_BUSBAR_PER_SUB) # check env_2 is not modified + + with warnings.catch_warnings(): + warnings.filterwarnings("ignore") + env_1 = grid2op.make("l2rpn_case14_sandbox", backend=_AuxFakeBackendSupport(), test=True, n_busbar=1, _add_to_name=type(self).__name__+"_same_name") + self._aux_fun_test(env_1, 1) # check env_1 has indeed 3 buses + self._aux_fun_test(env_3, 3) # check env_3 is not modified + self._aux_fun_test(env_2, DEFAULT_N_BUSBAR_PER_SUB) # check env_2 is not modified + + +class _TestAgentRightNbBus(BaseAgent): + def __init__(self, action_space: ActionSpace, nb_bus : int): + super().__init__(action_space) + self.nb_bus = nb_bus + assert type(self.action_space).n_busbar_per_sub == self.nb_bus + + def act(self, observation: BaseObservation, reward: float, done: bool = False) -> BaseAction: + assert type(observation).n_busbar_per_sub == self.nb_bus + return self.action_space() + + +class TestRunnerNbBus(unittest.TestCase): + """Testthe runner is compatible with the feature""" + def test_single_process(self): + with warnings.catch_warnings(): + warnings.filterwarnings("ignore") + # 3 busbars as asked + env_3 = grid2op.make("l2rpn_case14_sandbox", backend=_AuxFakeBackendSupport(), test=True, n_busbar=3, _add_to_name=type(self).__name__+"_3") + + with warnings.catch_warnings(): + warnings.filterwarnings("ignore") + # 2 busbars only because backend does not support it + env_2 = grid2op.make("l2rpn_case14_sandbox", backend=_AuxFakeBackendNoSupport(), test=True, n_busbar=3, _add_to_name=type(self).__name__+"_2") + + agent_3 = _TestAgentRightNbBus(env_3.action_space, 3) + agent_2 = _TestAgentRightNbBus(env_2.action_space, 2) + + runner_3 = Runner(**env_3.get_params_for_runner(), agentClass=None, agentInstance=agent_3) + res = runner_3.run(nb_episode=1, max_iter=5) + + runner_2 = Runner(**env_2.get_params_for_runner(), agentClass=None, agentInstance=agent_2) + res = runner_2.run(nb_episode=1, max_iter=5) + + with self.assertRaises(AssertionError): + runner_3_ko = Runner(**env_3.get_params_for_runner(), agentClass=None, agentInstance=agent_2) + res = runner_3_ko.run(nb_episode=1, max_iter=5) + + with self.assertRaises(AssertionError): + runner_2_ko = Runner(**env_2.get_params_for_runner(), agentClass=None, agentInstance=agent_3) + res = runner_2_ko.run(nb_episode=1, max_iter=5) + + def test_two_env_same_name(self): + with warnings.catch_warnings(): + warnings.filterwarnings("ignore") + env_2 = grid2op.make("l2rpn_case14_sandbox", backend=_AuxFakeBackendSupport(), test=True, _add_to_name=type(self).__name__+"_same_name") + + agent_2 = _TestAgentRightNbBus(env_2.action_space, 2) + runner_2 = Runner(**env_2.get_params_for_runner(), agentClass=None, agentInstance=agent_2) + res = runner_2.run(nb_episode=1, max_iter=5) + + with warnings.catch_warnings(): + warnings.filterwarnings("ignore") + env_3 = grid2op.make("l2rpn_case14_sandbox", backend=_AuxFakeBackendSupport(), test=True, n_busbar=3, _add_to_name=type(self).__name__+"_same_name") + agent_3 = _TestAgentRightNbBus(env_3.action_space, 3) + runner_3 = Runner(**env_3.get_params_for_runner(), agentClass=None, agentInstance=agent_3) + res = runner_3.run(nb_episode=1, max_iter=5) + + with self.assertRaises(AssertionError): + runner_3_ko = Runner(**env_3.get_params_for_runner(), agentClass=None, agentInstance=agent_2) + res = runner_3_ko.run(nb_episode=1, max_iter=5) + + with self.assertRaises(AssertionError): + runner_2_ko = Runner(**env_2.get_params_for_runner(), agentClass=None, agentInstance=agent_3) + res = runner_2_ko.run(nb_episode=1, max_iter=5) + + def test_two_process(self): + with warnings.catch_warnings(): + warnings.filterwarnings("ignore") + # 3 busbars as asked + env_3 = grid2op.make("l2rpn_case14_sandbox", backend=_AuxFakeBackendSupport(), test=True, n_busbar=3, _add_to_name=type(self).__name__+"_3_twocores") + + with warnings.catch_warnings(): + warnings.filterwarnings("ignore") + # 2 busbars only because backend does not support it + env_2 = grid2op.make("l2rpn_case14_sandbox", backend=_AuxFakeBackendNoSupport(), test=True, n_busbar=3, _add_to_name=type(self).__name__+"_2_twocores") + + agent_3 = _TestAgentRightNbBus(env_3.action_space, 3) + agent_2 = _TestAgentRightNbBus(env_2.action_space, 2) + + runner_3 = Runner(**env_3.get_params_for_runner(), agentClass=None, agentInstance=agent_3) + res = runner_3.run(nb_episode=2, nb_process=2, max_iter=5) + + runner_2 = Runner(**env_2.get_params_for_runner(), agentClass=None, agentInstance=agent_2) + res = runner_2.run(nb_episode=2, nb_process=2, max_iter=5) + + # with self.assertRaises(multiprocessing.pool.RemoteTraceback): + with self.assertRaises(AssertionError): + runner_3_ko = Runner(**env_3.get_params_for_runner(), agentClass=None, agentInstance=agent_2) + res = runner_3_ko.run(nb_episode=2, nb_process=2, max_iter=5) + + +class TestGridObjtNbBus(unittest.TestCase): + """Test that the GridObj class is fully compatible with this feature""" + def setUp(self) -> None: + with warnings.catch_warnings(): + warnings.filterwarnings("ignore") + self.env = grid2op.make("l2rpn_case14_sandbox", + backend=_AuxFakeBackendSupport(), + test=True, + n_busbar=3, + _add_to_name=type(self).__name__) + return super().setUp() + + def tearDown(self) -> None: + self.env.close() + return super().tearDown() + + def test_global_bus_to_local_int(self): + """test the function :func:`grid2op.Space.GridObjects.global_bus_to_local_int` """ + cls_env = type(self.env) + # easy case: everything on bus 1 + res = cls_env.global_bus_to_local_int(cls_env.gen_to_subid[0], cls_env.gen_to_subid[0]) + assert res == 1 + + # bit less easy: one generator is disconnected + gen_off = 2 + res = cls_env.global_bus_to_local_int(-1, cls_env.gen_to_subid[gen_off]) + assert res == -1 + + # still a bit more complex: one gen on busbar 2 + gen_on_2 = 3 + res = cls_env.global_bus_to_local_int(cls_env.gen_to_subid[gen_on_2] + cls_env.n_sub, cls_env.gen_to_subid[gen_on_2]) + assert res == 2 + + # and now a generator on busbar 3 + gen_on_3 = 4 + res = cls_env.global_bus_to_local_int(cls_env.gen_to_subid[gen_on_3] + 2 * cls_env.n_sub, cls_env.gen_to_subid[gen_on_3]) + assert res == 3 + + with self.assertRaises(EnvError): + gen_on_4 = 4 + res = cls_env.global_bus_to_local_int(cls_env.gen_to_subid[gen_on_4] + 3 * cls_env.n_sub, cls_env.gen_to_subid[gen_on_4]) + + def test_global_bus_to_local(self): + """test the function :func:`grid2op.Space.GridObjects.global_bus_to_local` """ + cls_env = type(self.env) + # easy case: everything on bus 1 + res = cls_env.global_bus_to_local(cls_env.gen_to_subid, cls_env.gen_to_subid) + assert (res == np.ones(cls_env.n_gen, dtype=int)).all() + + # bit less easy: one generator is disconnected + gen_off = 2 + inp_vect = 1 * cls_env.gen_to_subid + inp_vect[gen_off] = -1 + res = cls_env.global_bus_to_local(inp_vect, cls_env.gen_to_subid) + vect = np.ones(cls_env.n_gen, dtype=int) + vect[gen_off] = -1 + assert (res == vect).all() + + # still a bit more complex: one gen on busbar 2 + gen_on_2 = 3 + inp_vect = 1 * cls_env.gen_to_subid + inp_vect[gen_on_2] = cls_env.gen_to_subid[gen_on_2] + cls_env.n_sub + res = cls_env.global_bus_to_local(inp_vect, cls_env.gen_to_subid) + vect = np.ones(cls_env.n_gen, dtype=int) + vect[gen_on_2] = 2 + assert (res == vect).all() + + # and now a generator on busbar 3 + gen_on_3 = 4 + inp_vect = 1 * cls_env.gen_to_subid + inp_vect[gen_on_3] = cls_env.gen_to_subid[gen_on_3] + 2 * cls_env.n_sub + res = cls_env.global_bus_to_local(inp_vect, cls_env.gen_to_subid) + vect = np.ones(cls_env.n_gen, dtype=int) + vect[gen_on_3] = 3 + assert (res == vect).all() + + # and now we mix all + inp_vect = 1 * cls_env.gen_to_subid + inp_vect[gen_off] = -1 + inp_vect[gen_on_2] = cls_env.gen_to_subid[gen_on_2] + cls_env.n_sub + inp_vect[gen_on_3] = cls_env.gen_to_subid[gen_on_3] + 2 * cls_env.n_sub + res = cls_env.global_bus_to_local(inp_vect, cls_env.gen_to_subid) + vect = np.ones(cls_env.n_gen, dtype=int) + vect[gen_off] = -1 + vect[gen_on_2] = 2 + vect[gen_on_3] = 3 + assert (res == vect).all() + + def test_local_bus_to_global_int(self): + """test the function :func:`grid2op.Space.GridObjects.local_bus_to_global_int` """ + cls_env = type(self.env) + # easy case: everything on bus 1 + res = cls_env.local_bus_to_global_int(1, cls_env.gen_to_subid[0]) + assert res == cls_env.gen_to_subid[0] + + # bit less easy: one generator is disconnected + gen_off = 2 + res = cls_env.local_bus_to_global_int(-1, cls_env.gen_to_subid[gen_off]) + assert res == -1 + + # still a bit more complex: one gen on busbar 2 + gen_on_2 = 3 + res = cls_env.local_bus_to_global_int(2, cls_env.gen_to_subid[gen_on_2]) + assert res == cls_env.gen_to_subid[gen_on_2] + cls_env.n_sub + + # and now a generator on busbar 3 + gen_on_3 = 4 + res = cls_env.local_bus_to_global_int(3, cls_env.gen_to_subid[gen_on_3]) + assert res == cls_env.gen_to_subid[gen_on_3] + 2 * cls_env.n_sub + + def test_local_bus_to_global(self): + """test the function :func:`grid2op.Space.GridObjects.local_bus_to_global` """ + cls_env = type(self.env) + # easy case: everything on bus 1 + res = cls_env.local_bus_to_global(np.ones(cls_env.n_gen, dtype=int), cls_env.gen_to_subid) + assert (res == cls_env.gen_to_subid).all() + + # bit less easy: one generator is disconnected + gen_off = 2 + vect = np.ones(cls_env.n_gen, dtype=int) + vect[gen_off] = -1 + res = cls_env.local_bus_to_global(vect, cls_env.gen_to_subid) + assert (res == cls_env.gen_to_subid).sum() == cls_env.n_gen - 1 + assert res[gen_off] == -1 + + # still a bit more complex: one gen on busbar 2 + gen_on_2 = 3 + vect = np.ones(cls_env.n_gen, dtype=int) + vect[gen_on_2] = 2 + res = cls_env.local_bus_to_global(vect, cls_env.gen_to_subid) + assert (res == cls_env.gen_to_subid).sum() == cls_env.n_gen - 1 + assert res[gen_on_2] == cls_env.gen_to_subid[gen_on_2] + cls_env.n_sub + + # and now a generator on busbar 3 + gen_on_3 = 4 + vect = np.ones(cls_env.n_gen, dtype=int) + vect[gen_on_3] = 3 + res = cls_env.local_bus_to_global(vect, cls_env.gen_to_subid) + assert (res == cls_env.gen_to_subid).sum() == cls_env.n_gen - 1 + assert res[gen_on_3] == cls_env.gen_to_subid[gen_on_3] + 2 * cls_env.n_sub + + # and now we mix all + vect = np.ones(cls_env.n_gen, dtype=int) + vect[gen_off] = -1 + vect[gen_on_2] = 2 + vect[gen_on_3] = 3 + res = cls_env.local_bus_to_global(vect, cls_env.gen_to_subid) + assert res[gen_off] == -1 + assert res[gen_on_2] == cls_env.gen_to_subid[gen_on_2] + cls_env.n_sub + assert res[gen_on_3] == cls_env.gen_to_subid[gen_on_3] + 2 * cls_env.n_sub + + +class TestAction_3busbars(unittest.TestCase): + """This class test the Agent can perform actions (and that actions are properly working) + even if there are 3 busbars per substation + """ + def get_nb_bus(self): + return 3 + + def setUp(self) -> None: + with warnings.catch_warnings(): + warnings.filterwarnings("ignore") + self.env = grid2op.make("educ_case14_storage", + backend=_AuxFakeBackendSupport(), + action_class=CompleteAction, + test=True, + n_busbar=self.get_nb_bus(), + _add_to_name=type(self).__name__ + f'_{self.get_nb_bus()}') + return super().setUp() + + def tearDown(self) -> None: + self.env.close() + return super().tearDown() + + def _aux_test_act_consistent_as_dict(self, act_as_dict, name_xxx, el_id, bus_val): + if name_xxx is not None: + # regular element in the topo_vect + assert "set_bus_vect" in act_as_dict + tmp = act_as_dict["set_bus_vect"] + assert len(tmp['modif_subs_id']) == 1 + sub_id = tmp['modif_subs_id'][0] + assert name_xxx[el_id] in tmp[sub_id] + assert tmp[sub_id][name_xxx[el_id]]["new_bus"] == bus_val + else: + # el not in topo vect (eg shunt) + assert "shunt" in act_as_dict + tmp = act_as_dict["shunt"]["shunt_bus"] + assert tmp[el_id] == bus_val + + def _aux_test_act_consistent_as_serializable_dict(self, act_as_dict, el_nms, el_id, bus_val): + if el_nms is not None: + # regular element + assert "set_bus" in act_as_dict + assert el_nms in act_as_dict["set_bus"] + tmp = act_as_dict["set_bus"][el_nms] + assert tmp == [(el_id, bus_val)] + else: + # shunts of other things not in the topo vect + assert "shunt" in act_as_dict + tmp = act_as_dict["shunt"]["shunt_bus"] + assert tmp == [(el_id, bus_val)] + + def _aux_test_action(self, act : BaseAction, name_xxx, el_id, bus_val, el_nms): + assert act.can_affect_something() + assert not act.is_ambiguous()[0] + tmp = f"{act}" # test the print does not crash + tmp = act.as_dict() # test I can convert to dict + self._aux_test_act_consistent_as_dict(tmp, name_xxx, el_id, bus_val) + tmp = act.as_serializable_dict() # test I can convert to another type of dict + self._aux_test_act_consistent_as_serializable_dict(tmp, el_nms, el_id, bus_val) + + def _aux_test_set_bus_onebus(self, nm_prop, el_id, bus_val, name_xxx, el_nms): + act = self.env.action_space() + setattr(act, nm_prop, [(el_id, bus_val)]) + self._aux_test_action(act, name_xxx, el_id, bus_val, el_nms) + + def test_set_load_bus(self): + self._aux_test_set_bus_onebus("load_set_bus", 0, -1, type(self.env).name_load, 'loads_id') + for bus in range(type(self.env).n_busbar_per_sub): + self._aux_test_set_bus_onebus("load_set_bus", 0, bus + 1, type(self.env).name_load, 'loads_id') + act = self.env.action_space() + with self.assertRaises(IllegalAction): + act.load_set_bus = [(0, type(self.env).n_busbar_per_sub + 1)] + + def test_set_gen_bus(self): + self._aux_test_set_bus_onebus("gen_set_bus", 0, -1, type(self.env).name_gen, 'generators_id') + for bus in range(type(self.env).n_busbar_per_sub): + self._aux_test_set_bus_onebus("gen_set_bus", 0, bus + 1, type(self.env).name_gen, 'generators_id') + act = self.env.action_space() + with self.assertRaises(IllegalAction): + act.gen_set_bus = [(0, type(self.env).n_busbar_per_sub + 1)] + + def test_set_storage_bus(self): + self._aux_test_set_bus_onebus("storage_set_bus", 0, -1, type(self.env).name_storage, 'storages_id') + for bus in range(type(self.env).n_busbar_per_sub): + self._aux_test_set_bus_onebus("storage_set_bus", 0, bus + 1, type(self.env).name_storage, 'storages_id') + act = self.env.action_space() + with self.assertRaises(IllegalAction): + act.storage_set_bus = [(0, type(self.env).n_busbar_per_sub + 1)] + + def test_set_lineor_bus(self): + self._aux_test_set_bus_onebus("line_or_set_bus", 0, -1, type(self.env).name_line, 'lines_or_id') + for bus in range(type(self.env).n_busbar_per_sub): + self._aux_test_set_bus_onebus("line_or_set_bus", 0, bus + 1, type(self.env).name_line, 'lines_or_id') + act = self.env.action_space() + with self.assertRaises(IllegalAction): + act.line_or_set_bus = [(0, type(self.env).n_busbar_per_sub + 1)] + + def test_set_lineex_bus(self): + self._aux_test_set_bus_onebus("line_ex_set_bus", 0, -1, type(self.env).name_line, 'lines_ex_id') + for bus in range(type(self.env).n_busbar_per_sub): + self._aux_test_set_bus_onebus("line_ex_set_bus", 0, bus + 1, type(self.env).name_line, 'lines_ex_id') + act = self.env.action_space() + with self.assertRaises(IllegalAction): + act.line_ex_set_bus = [(0, type(self.env).n_busbar_per_sub + 1)] + + def _aux_test_set_bus_onebus_sub_setbus(self, nm_prop, sub_id, el_id_sub, bus_val, name_xxx, el_nms): + # for now works only with lines_ex (in other words, the name_xxx and name_xxx should be + # provided by the user and it's probably not a good idea to use something + # else than type(self.env).name_line and lines_ex_id + act = self.env.action_space() + buses_val = np.zeros(type(self.env).sub_info[sub_id], dtype=int) + buses_val[el_id_sub] = bus_val + setattr(act, nm_prop, [(sub_id, buses_val)]) + el_id_in_topo_vect = np.where(act._set_topo_vect == bus_val)[0][0] + el_type = np.where(type(self.env).grid_objects_types[el_id_in_topo_vect][1:] != -1)[0][0] + el_id = type(self.env).grid_objects_types[el_id_in_topo_vect][el_type + 1] + self._aux_test_action(act, name_xxx, el_id, bus_val, el_nms) + + def test_sub_set_bus(self): + self._aux_test_set_bus_onebus_sub_setbus("sub_set_bus", 1, 0, -1, type(self.env).name_line, 'lines_ex_id') + for bus in range(type(self.env).n_busbar_per_sub): + self._aux_test_set_bus_onebus_sub_setbus("sub_set_bus", 1, 0, bus + 1, type(self.env).name_line, 'lines_ex_id') + act = self.env.action_space() + with self.assertRaises(IllegalAction): + act.line_ex_set_bus = [(0, type(self.env).n_busbar_per_sub + 1)] + + def test_change_deactivated(self): + assert "set_bus" in type(self.env.action_space()).authorized_keys + assert self.env.action_space.supports_type("set_bus") + + assert "change_bus" not in type(self.env.action_space()).authorized_keys + assert not self.env.action_space.supports_type("change_bus") + + def _aux_test_action_shunt(self, act : BaseAction, el_id, bus_val): + name_xxx = None + el_nms = None + # self._aux_test_action(act, type(self.env).name_shunt, el_id, bus_val, None) # does not work for a lot of reasons + assert not act.is_ambiguous()[0] + tmp = f"{act}" # test the print does not crash + tmp = act.as_dict() # test I can convert to dict + self._aux_test_act_consistent_as_dict(tmp, name_xxx, el_id, bus_val) + tmp = act.as_serializable_dict() # test I can convert to another type of dict + self._aux_test_act_consistent_as_serializable_dict(tmp, el_nms, el_id, bus_val) + + def test_shunt(self): + el_id = 0 + bus_val = -1 + act = self.env.action_space({"shunt": {"set_bus": [(el_id, bus_val)]}}) + self._aux_test_action_shunt(act, el_id, bus_val) + + for bus_val in range(type(self.env).n_busbar_per_sub): + act = self.env.action_space({"shunt": {"set_bus": [(el_id, bus_val + 1)]}}) + self._aux_test_action_shunt(act, el_id, bus_val + 1) + + act = self.env.action_space() + with self.assertRaises(IllegalAction): + act = self.env.action_space({"shunt": {"set_bus": [(el_id, type(self.env).n_busbar_per_sub + 1)]}}) + + +class TestAction_1busbar(TestAction_3busbars): + """This class test the Agent can perform actions (and that actions are properly working) + even if there is only 1 busbar per substation + """ + def get_nb_bus(self): + return 1 + + +class TestActionSpaceNbBus(unittest.TestCase): + """This function test the action space, basically the counting + of unique possible topologies per substation + """ + def get_nb_bus(self): + return 3 + + def get_env_nm(self): + return "educ_case14_storage" + + def setUp(self) -> None: + with warnings.catch_warnings(): + warnings.filterwarnings("ignore") + self.env = grid2op.make(self.get_env_nm(), + backend=_AuxFakeBackendSupport(), + action_class=CompleteAction, + test=True, + n_busbar=self.get_nb_bus(), + _add_to_name=type(self).__name__ + f'_{self.get_nb_bus()}') + return super().setUp() + + def tearDown(self) -> None: + self.env.close() + return super().tearDown() + + def test_legacy_all_unitary_topologies_set_behaviour(self): + """make sure nothing broke for 2 busbars per substation even if the implementation changes""" + class SubMe(TestActionSpaceNbBus): + def get_nb_bus(self): + return 2 + + tmp = SubMe() + tmp.setUp() + res = tmp.env.action_space.get_all_unitary_topologies_set(tmp.env.action_space, _count_only=True) + res_noalone = tmp.env.action_space.get_all_unitary_topologies_set(tmp.env.action_space, + add_alone_line=False, + _count_only=True) + tmp.tearDown() + assert res == [3, 29, 5, 31, 15, 113, 4, 0, 15, 3, 3, 3, 7, 3], f"found: {res}" + assert res_noalone == [0, 25, 3, 26, 11, 109, 0, 0, 11, 0, 0, 0, 4, 0], f"found: {res_noalone}" + + class SubMe2(TestActionSpaceNbBus): + def get_nb_bus(self): + return 2 + def get_env_nm(self): + return "l2rpn_idf_2023" + tmp2 = SubMe2() + tmp2.setUp() + res = tmp2.env.action_space.get_all_unitary_topologies_set(tmp2.env.action_space, _count_only=True) + res_noalone = tmp2.env.action_space.get_all_unitary_topologies_set(tmp2.env.action_space, + add_alone_line=False, + _count_only=True) + tmp2.tearDown() + assert res == [3, 3, 7, 9, 16, 3, 3, 13, 2, 0, 57, 253, 3, 3, 241, 3, 63, 5, 29, 3, + 3, 3, 29, 7, 7, 3, 57, 3, 3, 8, 7, 31, 3, 29, 3, 3, 32, 4, 3, 29, 3, + 113, 3, 3, 13, 13, 7, 3, 65505, 3, 7, 3, 3, 125, 13, 497, 3, 3, 505, + 13, 15, 57, 2, 4, 15, 61, 3, 8, 63, 121, 4, 3, 0, 3, 31, 5, 1009, 3, + 3, 1017, 2, 7, 13, 3, 61, 3, 0, 3, 63, 25, 3, 253, 3, 31, 3, 61, 3, + 3, 3, 2033, 3, 3, 15, 13, 61, 7, 5, 3, 3, 15, 0, 0, 9, 3, 3, 0, 0, 3], f"found: {res}" + assert res_noalone == [0, 0, 4, 7, 11, 0, 0, 10, 0, 0, 53, 246, 0, 0, 236, 0, 57, 3, + 25, 0, 0, 0, 25, 4, 4, 0, 53, 0, 0, 4, 4, 26, 0, 25, 0, 0, 26, + 0, 0, 25, 0, 109, 0, 0, 10, 10, 4, 0, 65493, 0, 4, 0, 0, 119, + 10, 491, 0, 0, 498, 10, 11, 53, 0, 0, 11, 56, 0, 4, 57, 116, + 0, 0, 0, 0, 26, 3, 1002, 0, 0, 1009, 0, 4, 10, 0, 56, 0, 0, + 0, 57, 22, 0, 246, 0, 26, 0, 56, 0, 0, 0, 2025, 0, 0, 11, 10, + 56, 4, 3, 0, 0, 11, 0, 0, 7, 0, 0, 0, 0, 0], f"found: {res_noalone}" + + def test_is_ok_symmetry(self): + """test the :func:`grid2op.Action.SerializableActionSpace._is_ok_symmetry`""" + ok = np.array([1, 1, 1, 1]) + assert type(self.env.action_space)._is_ok_symmetry(2, ok), f"should not break for {ok}" + ok = np.array([1, 2, 1, 1]) + assert type(self.env.action_space)._is_ok_symmetry(2, ok), f"should not break for {ok}" + ok = np.array([1, 2, 3, 1]) + assert type(self.env.action_space)._is_ok_symmetry(3, ok), f"should not break for {ok}" + ok = np.array([1, 1, 2, 3]) + assert type(self.env.action_space)._is_ok_symmetry(3, ok), f"should not break for {ok}" + ok = np.array([1, 1, 2, 2]) + assert type(self.env.action_space)._is_ok_symmetry(4, ok), f"should not break for {ok}" + + ko = np.array([1, 3, 2, 1]) # relabel 3 -> 2, so this topology is not valid + assert not type(self.env.action_space)._is_ok_symmetry(3, ko), f"should break for {ko}" + ko = np.array([1, 1, 3, 2]) # relabel 3 -> 2, so this topology is not valid + assert not type(self.env.action_space)._is_ok_symmetry(3, ko), f"should break for {ko}" + + ko = np.array([1, 3, 2, 1]) # relabel 3 -> 2, so this topology is not valid + assert not type(self.env.action_space)._is_ok_symmetry(4, ko), f"should break for {ko}" + ko = np.array([1, 1, 3, 2]) # relabel 3 -> 2, so this topology is not valid + assert not type(self.env.action_space)._is_ok_symmetry(4, ko), f"should break for {ko}" + + def test_is_ok_line(self): + """test the :func:`grid2op.Action.SerializableActionSpace._is_ok_line`""" + lines_id = np.array([1, 3]) + n_busbar_per_sub = 2 + ok = np.array([1, 1, 1, 1]) + assert type(self.env.action_space)._is_ok_line(n_busbar_per_sub, ok, lines_id), f"should not break for {ok}" + ok = np.array([1, 2, 2, 1]) + assert type(self.env.action_space)._is_ok_line(n_busbar_per_sub, ok, lines_id), f"should not break for {ok}" + ko = np.array([1, 2, 1, 2]) # no lines on bus 1 + assert not type(self.env.action_space)._is_ok_line(n_busbar_per_sub, ko, lines_id), f"should break for {ko}" + + n_busbar_per_sub = 3 # should have no impact + ok = np.array([1, 1, 1, 1]) + assert type(self.env.action_space)._is_ok_line(n_busbar_per_sub, ok, lines_id), f"should not break for {ok}" + ok = np.array([1, 2, 2, 1]) + assert type(self.env.action_space)._is_ok_line(n_busbar_per_sub, ok, lines_id), f"should not break for {ok}" + ko = np.array([1, 2, 1, 2]) # no lines on bus 1 + assert not type(self.env.action_space)._is_ok_line(n_busbar_per_sub, ko, lines_id), f"should break for {ko}" + + def test_2_obj_per_bus(self): + """test the :func:`grid2op.Action.SerializableActionSpace._is_ok_2`""" + n_busbar_per_sub = 2 + ok = np.array([1, 1, 1, 1]) + assert type(self.env.action_space)._is_ok_2(n_busbar_per_sub, ok), f"should not break for {ok}" + ok = np.array([1, 2, 2, 1]) + assert type(self.env.action_space)._is_ok_2(n_busbar_per_sub, ok), f"should not break for {ok}" + ok = np.array([1, 2, 1, 2]) + assert type(self.env.action_space)._is_ok_2(n_busbar_per_sub, ok), f"should not break for {ok}" + + ko = np.array([1, 2, 2, 2]) # only 1 element on bus 1 + assert not type(self.env.action_space)._is_ok_2(n_busbar_per_sub, ko), f"should break for {ko}" + ko = np.array([1, 2, 1, 1]) # only 1 element on bus 2 + assert not type(self.env.action_space)._is_ok_2(n_busbar_per_sub, ko), f"should break for {ko}" + ko = np.array([1, 1, 2, 2, 3]) # only 1 element on bus 3 + assert not type(self.env.action_space)._is_ok_2(n_busbar_per_sub, ko), f"should break for {ko}" + + n_busbar_per_sub = 3 + ok = np.array([1, 1, 1, 1]) + assert type(self.env.action_space)._is_ok_2(n_busbar_per_sub, ok), f"should not break for {ok}" + ok = np.array([1, 2, 2, 1]) + assert type(self.env.action_space)._is_ok_2(n_busbar_per_sub, ok), f"should not break for {ok}" + ok = np.array([1, 2, 1, 2]) + assert type(self.env.action_space)._is_ok_2(n_busbar_per_sub, ok), f"should not break for {ok}" + + ko = np.array([1, 2, 2, 2]) # only 1 element on bus 1 + assert not type(self.env.action_space)._is_ok_2(n_busbar_per_sub, ko), f"should break for {ko}" + ko = np.array([1, 2, 1, 1]) # only 1 element on bus 2 + assert not type(self.env.action_space)._is_ok_2(n_busbar_per_sub, ko), f"should break for {ko}" + ko = np.array([1, 1, 2, 2, 3]) # only 1 element on bus 3 + assert not type(self.env.action_space)._is_ok_2(n_busbar_per_sub, ko), f"should break for {ko}" + + def test_1_busbar(self): + """test :func:`grid2op.Action.SerializableActionSpace.get_all_unitary_topologies_set` + when there are only 1 busbar per substation""" + class SubMe(TestActionSpaceNbBus): + def get_nb_bus(self): + return 1 + + tmp = SubMe() + tmp.setUp() + res = [len(tmp.env.action_space.get_all_unitary_topologies_set(tmp.env.action_space, + sub_id)) + for sub_id in range(type(tmp.env).n_sub)] + res_noalone = [len(tmp.env.action_space.get_all_unitary_topologies_set(tmp.env.action_space, + sub_id, + add_alone_line=False)) + for sub_id in range(type(tmp.env).n_sub)] + tmp.tearDown() + assert res == [0] * 14, f"found: {res}" + assert res_noalone == [0] * 14, f"found: {res_noalone}" + + class SubMe2(TestActionSpaceNbBus): + def get_nb_bus(self): + return 1 + def get_env_nm(self): + return "l2rpn_idf_2023" + + tmp2 = SubMe2() + tmp2.setUp() + res = [len(tmp2.env.action_space.get_all_unitary_topologies_set(tmp2.env.action_space, + sub_id)) + for sub_id in range(type(tmp2.env).n_sub)] + res_noalone = [len(tmp2.env.action_space.get_all_unitary_topologies_set(tmp2.env.action_space, + sub_id, + add_alone_line=False)) + for sub_id in range(type(tmp2.env).n_sub)] + tmp2.tearDown() + assert res == [0] * 118, f"found: {res}" + assert res_noalone == [0] * 118, f"found: {res_noalone}" + + def test_3_busbars(self): + """test :func:`grid2op.Action.SerializableActionSpace.get_all_unitary_topologies_set` + when there are 3 busbars per substation""" + res = self.env.action_space.get_all_unitary_topologies_set(self.env.action_space, + _count_only=True) + res_noalone = self.env.action_space.get_all_unitary_topologies_set(self.env.action_space, + add_alone_line=False, + _count_only=True) + assert res == [3, 83, 5, 106, 33, 599, 5, 0, 33, 3, 3, 3, 10, 3], f"found: {res}" + assert res_noalone == [0, 37, 3, 41, 11, 409, 0, 0, 11, 0, 0, 0, 4, 0], f"found: {res_noalone}" + class SubMe2(TestActionSpaceNbBus): + def get_nb_bus(self): + return 3 + def get_env_nm(self): + return "l2rpn_idf_2023" + tmp2 = SubMe2() + tmp2.setUp() + th_vals = [0, 0, 4, 7, 11, 0, 0, 10, 0, 0, 125, 2108, 0, 0, 1711, 0, 162, 3, 37, 0, 0, 0, 37, + 4, 4, 0, 125, 0, 0, 4, 4, 41, 0, 37, 0, 0, 41, 0, 0, 37, 0, 409, 0, 0, 10, 10, 4, 0] + for sub_id, th_val in zip(list(range(48)), th_vals): + res_noalone = tmp2.env.action_space.get_all_unitary_topologies_set(tmp2.env.action_space, + sub_id=sub_id, + add_alone_line=False, + _count_only=True) + assert res_noalone[0] == th_val, f"error for sub_id {sub_id}: {res_noalone} vs {th_val}" + + if HAS_TIME_AND_MEMORY: + # takes 850s (13 minutes) + res_noalone = tmp2.env.action_space.get_all_unitary_topologies_set(tmp2.env.action_space, + sub_id=48, + add_alone_line=False, + _count_only=True) + assert res_noalone == 20698545, f"error for sub_id {48}: {res_noalone}" + tmp2.tearDown() + + def test_legacy_all_unitary_line_set_behaviour(self): + """make sure nothing broke for 2 busbars per substation even if the implementation changes""" + class SubMe(TestActionSpaceNbBus): + def get_nb_bus(self): + return 2 + + tmp = SubMe() + tmp.setUp() + res = len(tmp.env.action_space.get_all_unitary_line_set(tmp.env.action_space)) + res_simple = len(tmp.env.action_space.get_all_unitary_line_set_simple(tmp.env.action_space)) + tmp.tearDown() + assert res == 5 * 20, f"found: {res}" + assert res_simple == 2 * 20, f"found: {res_simple}" + + class SubMe2(TestActionSpaceNbBus): + def get_nb_bus(self): + return 2 + def get_env_nm(self): + return "l2rpn_idf_2023" + + tmp2 = SubMe2() + tmp2.setUp() + res = len(tmp2.env.action_space.get_all_unitary_line_set(tmp2.env.action_space)) + res_simple = len(tmp2.env.action_space.get_all_unitary_line_set_simple(tmp2.env.action_space)) + tmp2.tearDown() + assert res == 5 * 186, f"found: {res}" + assert res_simple == 2 * 186, f"found: {res_simple}" + + def test_get_all_unitary_line_set(self): + """test the :func:`grid2op.Action.SerializableActionSpace.get_all_unitary_line_set` when 3 busbars""" + res = len(self.env.action_space.get_all_unitary_line_set(self.env.action_space)) + assert res == (1 + 3*3) * 20, f"found: {res}" + res = len(self.env.action_space.get_all_unitary_line_set_simple(self.env.action_space)) + assert res == 2 * 20, f"found: {res}" + class SubMe2(TestActionSpaceNbBus): + def get_nb_bus(self): + return 3 + def get_env_nm(self): + return "l2rpn_idf_2023" + + tmp2 = SubMe2() + tmp2.setUp() + res = len(tmp2.env.action_space.get_all_unitary_line_set(tmp2.env.action_space)) + res_simple = len(tmp2.env.action_space.get_all_unitary_line_set_simple(tmp2.env.action_space)) + tmp2.tearDown() + assert res == (1 + 3 * 3) * 186, f"found: {res}" + assert res_simple == 2 * 186, f"found: {res_simple}" + + +class TestBackendActionNbBus(unittest.TestCase): + def get_nb_bus(self): + return 3 + + def get_env_nm(self): + return "educ_case14_storage" + + def setUp(self) -> None: + with warnings.catch_warnings(): + warnings.filterwarnings("ignore") + self.env = grid2op.make(self.get_env_nm(), + backend=_AuxFakeBackendSupport(), + action_class=CompleteAction, + test=True, + n_busbar=self.get_nb_bus(), + _add_to_name=type(self).__name__ + f'_{self.get_nb_bus()}') + return super().setUp() + + def tearDown(self) -> None: + self.env.close() + return super().tearDown() + + def test_correct_last_topo(self): + line_id = 0 + id_topo_or = type(self.env).line_or_pos_topo_vect[line_id] + id_topo_ex = type(self.env).line_ex_pos_topo_vect[line_id] + + backend_action = self.env._backend_action + act = self.env.action_space({"set_bus": {"lines_or_id": [(line_id, -1)]}}) + backend_action += act + backend_action.reset() + assert backend_action.current_topo.values[id_topo_or] == -1, f"{backend_action.current_topo.values[id_topo_or]} vs -1" + assert backend_action.current_topo.values[id_topo_ex] == -1, f"{backend_action.current_topo.values[id_topo_ex]} vs -1" + assert backend_action.last_topo_registered.values[id_topo_or] == 1, f"{backend_action.last_topo_registered.values[id_topo_or]} vs 1" + assert backend_action.last_topo_registered.values[id_topo_ex] == 1, f"{backend_action.last_topo_registered.values[id_topo_or]} vs 1" + + act = self.env.action_space({"set_bus": {"lines_or_id": [(line_id, 2)]}}) + backend_action += act + backend_action.reset() + assert backend_action.current_topo.values[id_topo_or] == 2, f"{backend_action.current_topo.values[id_topo_or]} vs 2" + assert backend_action.current_topo.values[id_topo_ex] == 1, f"{backend_action.current_topo.values[id_topo_ex]} vs 1" + assert backend_action.last_topo_registered.values[id_topo_or] == 2, f"{backend_action.last_topo_registered.values[id_topo_or]} vs 2" + assert backend_action.last_topo_registered.values[id_topo_ex] == 1, f"{backend_action.last_topo_registered.values[id_topo_or]} vs 1" + + act = self.env.action_space({"set_bus": {"lines_or_id": [(line_id, -1)]}}) + backend_action += act + backend_action.reset() + assert backend_action.current_topo.values[id_topo_or] == -1, f"{backend_action.current_topo.values[id_topo_or]} vs -1" + assert backend_action.current_topo.values[id_topo_ex] == -1, f"{backend_action.current_topo.values[id_topo_ex]} vs -1" + assert backend_action.last_topo_registered.values[id_topo_or] == 2, f"{backend_action.last_topo_registered.values[id_topo_or]} vs 2" + assert backend_action.last_topo_registered.values[id_topo_ex] == 1, f"{backend_action.last_topo_registered.values[id_topo_or]} vs 1" + + act = self.env.action_space({"set_bus": {"lines_ex_id": [(line_id, 3)]}}) + backend_action += act + backend_action.reset() + assert backend_action.current_topo.values[id_topo_or] == 2, f"{backend_action.current_topo.values[id_topo_or]} vs 2" + assert backend_action.current_topo.values[id_topo_ex] == 3, f"{backend_action.current_topo.values[id_topo_ex]} vs 3" + assert backend_action.last_topo_registered.values[id_topo_or] == 2, f"{backend_action.last_topo_registered.values[id_topo_or]} vs 2" + assert backend_action.last_topo_registered.values[id_topo_ex] == 3, f"{backend_action.last_topo_registered.values[id_topo_or]} vs 3" + + act = self.env.action_space({"set_bus": {"lines_or_id": [(line_id, -1)]}}) + backend_action += act + backend_action.reset() + assert backend_action.current_topo.values[id_topo_or] == -1, f"{backend_action.current_topo.values[id_topo_or]} vs -1" + assert backend_action.current_topo.values[id_topo_ex] == -1, f"{backend_action.current_topo.values[id_topo_ex]} vs -1" + assert backend_action.last_topo_registered.values[id_topo_or] == 2, f"{backend_action.last_topo_registered.values[id_topo_or]} vs 2" + assert backend_action.last_topo_registered.values[id_topo_ex] == 3, f"{backend_action.last_topo_registered.values[id_topo_or]} vs 3" + + act = self.env.action_space({"set_bus": {"lines_or_id": [(line_id, -1)]}}) + backend_action += act + backend_action.reset() + assert backend_action.current_topo.values[id_topo_or] == -1, f"{backend_action.current_topo.values[id_topo_or]} vs -1" + assert backend_action.current_topo.values[id_topo_ex] == -1, f"{backend_action.current_topo.values[id_topo_ex]} vs -1" + assert backend_action.last_topo_registered.values[id_topo_or] == 2, f"{backend_action.last_topo_registered.values[id_topo_or]} vs 2" + assert backend_action.last_topo_registered.values[id_topo_ex] == 3, f"{backend_action.last_topo_registered.values[id_topo_or]} vs 3" + + act = self.env.action_space({"set_bus": {"lines_or_id": [(line_id, 1)]}}) + backend_action += act + backend_action.reset() + assert backend_action.current_topo.values[id_topo_or] == 1, f"{backend_action.current_topo.values[id_topo_or]} vs 1" + assert backend_action.current_topo.values[id_topo_ex] == 3, f"{backend_action.current_topo.values[id_topo_ex]} vs 3" + assert backend_action.last_topo_registered.values[id_topo_or] == 1, f"{backend_action.last_topo_registered.values[id_topo_or]} vs 1" + assert backend_action.last_topo_registered.values[id_topo_ex] == 3, f"{backend_action.last_topo_registered.values[id_topo_or]} vs 3" + + def test_call(self): + cls = type(self.env) + line_id = 0 + id_topo_or = cls.line_or_pos_topo_vect[line_id] + id_topo_ex = cls.line_ex_pos_topo_vect[line_id] + + backend_action = self.env._backend_action + act = self.env.action_space({"set_bus": {"lines_or_id": [(line_id, -1)]}}) + backend_action += act + ( + active_bus, + (prod_p, prod_v, load_p, load_q, storage), + topo__, + shunts__, + ) = backend_action() + assert topo__.values[cls.line_or_pos_topo_vect[line_id]] == -1 + assert topo__.values[cls.line_ex_pos_topo_vect[line_id]] == -1 + backend_action.reset() + + act = self.env.action_space({"set_bus": {"lines_or_id": [(line_id, 2)]}}) + backend_action += act + ( + active_bus, + (prod_p, prod_v, load_p, load_q, storage), + topo__, + shunts__, + ) = backend_action() + assert topo__.values[cls.line_or_pos_topo_vect[line_id]] == 2 + assert topo__.values[cls.line_ex_pos_topo_vect[line_id]] == 1 + backend_action.reset() + + act = self.env.action_space({"set_bus": {"lines_or_id": [(line_id, -1)]}}) + backend_action += act + ( + active_bus, + (prod_p, prod_v, load_p, load_q, storage), + topo__, + shunts__, + ) = backend_action() + assert topo__.values[cls.line_or_pos_topo_vect[line_id]] == -1 + assert topo__.values[cls.line_ex_pos_topo_vect[line_id]] == -1 + backend_action.reset() + + act = self.env.action_space({"set_bus": {"lines_ex_id": [(line_id, 3)]}}) + backend_action += act + ( + active_bus, + (prod_p, prod_v, load_p, load_q, storage), + topo__, + shunts__, + ) = backend_action() + assert topo__.values[cls.line_or_pos_topo_vect[line_id]] == 2 + assert topo__.values[cls.line_ex_pos_topo_vect[line_id]] == 3 + backend_action.reset() + + act = self.env.action_space({"set_bus": {"lines_or_id": [(line_id, -1)]}}) + backend_action += act + ( + active_bus, + (prod_p, prod_v, load_p, load_q, storage), + topo__, + shunts__, + ) = backend_action() + assert topo__.values[cls.line_or_pos_topo_vect[line_id]] == -1 + assert topo__.values[cls.line_ex_pos_topo_vect[line_id]] == -1 + backend_action.reset() + + act = self.env.action_space({"set_bus": {"lines_or_id": [(line_id, 1)]}}) + backend_action += act + ( + active_bus, + (prod_p, prod_v, load_p, load_q, storage), + topo__, + shunts__, + ) = backend_action() + assert topo__.values[cls.line_or_pos_topo_vect[line_id]] == 1 + assert topo__.values[cls.line_ex_pos_topo_vect[line_id]] == 3 + backend_action.reset() + + +class TestPandapowerBackend_3busbars(unittest.TestCase): + def get_nb_bus(self): + return 3 + + def get_env_nm(self): + return "educ_case14_storage" + + def setUp(self) -> None: + with warnings.catch_warnings(): + warnings.filterwarnings("ignore") + self.env = grid2op.make(self.get_env_nm(), + backend=PandaPowerBackend(), + action_class=CompleteAction, + test=True, + n_busbar=self.get_nb_bus(), + _add_to_name=type(self).__name__ + f'_{self.get_nb_bus()}') + self.list_loc_bus = [-1] + list(range(1, type(self.env).n_busbar_per_sub + 1)) + return super().setUp() + + def tearDown(self) -> None: + self.env.close() + return super().tearDown() + + def test_right_bus_made(self): + assert self.env.backend._grid.bus.shape[0] == self.get_nb_bus() * type(self.env).n_sub + assert (~self.env.backend._grid.bus.iloc[type(self.env).n_sub:]["in_service"]).all() + + @staticmethod + def _aux_find_sub(env, obj_col): + """find a sub with 4 elements, the type of elements and at least 2 lines""" + cls = type(env) + res = None + for sub_id in range(cls.n_sub): + this_sub_mask = cls.grid_objects_types[:,cls.SUB_COL] == sub_id + this_sub = cls.grid_objects_types[this_sub_mask, :] + if this_sub.shape[0] <= 3: + # not enough element + continue + if (this_sub[:, obj_col] == -1).all(): + # no load + continue + if ((this_sub[:, cls.LOR_COL] != -1) | (this_sub[:, cls.LEX_COL] != -1)).sum() <= 1: + # only 1 line + continue + el_id = this_sub[this_sub[:, obj_col] != -1, obj_col][0] + if (this_sub[:, cls.LOR_COL] != -1).any(): + line_or_id = this_sub[this_sub[:, cls.LOR_COL] != -1, cls.LOR_COL][0] + line_ex_id = None + else: + line_or_id = None + line_ex_id = this_sub[this_sub[:, cls.LEX_COL] != -1, cls.LEX_COL][0] + res = (sub_id, el_id, line_or_id, line_ex_id) + break + return res + + @staticmethod + def _aux_find_sub_shunt(env): + """find a sub with 4 elements, the type of elements and at least 2 lines""" + cls = type(env) + res = None + for el_id in range(cls.n_shunt): + sub_id = cls.shunt_to_subid[el_id] + this_sub_mask = cls.grid_objects_types[:,cls.SUB_COL] == sub_id + this_sub = cls.grid_objects_types[this_sub_mask, :] + if this_sub.shape[0] <= 3: + # not enough element + continue + if ((this_sub[:, cls.LOR_COL] != -1) | (this_sub[:, cls.LEX_COL] != -1)).sum() <= 1: + # only 1 line + continue + if (this_sub[:, cls.LOR_COL] != -1).any(): + line_or_id = this_sub[this_sub[:, cls.LOR_COL] != -1, cls.LOR_COL][0] + line_ex_id = None + else: + line_or_id = None + line_ex_id = this_sub[this_sub[:, cls.LEX_COL] != -1, cls.LEX_COL][0] + res = (sub_id, el_id, line_or_id, line_ex_id) + break + return res + + def test_move_load(self): + cls = type(self.env) + res = self._aux_find_sub(self.env, cls.LOA_COL) + if res is None: + raise RuntimeError(f"Cannot carry the test 'test_move_load' as " + "there are no suitable subastation in your grid.") + (sub_id, el_id, line_or_id, line_ex_id) = res + for new_bus in self.list_loc_bus: + if line_or_id is not None: + act = self.env.action_space({"set_bus": {"loads_id": [(el_id, new_bus)], "lines_or_id": [(line_or_id, new_bus)]}}) + else: + act = self.env.action_space({"set_bus": {"loads_id": [(el_id, new_bus)], "lines_ex_id": [(line_ex_id, new_bus)]}}) + bk_act = self.env._backend_action_class() + bk_act += act + self.env.backend.apply_action(bk_act) + global_bus = sub_id + (new_bus -1) * cls.n_sub + if new_bus >= 1: + assert self.env.backend._grid.load.iloc[el_id]["bus"] == global_bus + if line_or_id is not None: + assert self.env.backend._grid.line.iloc[line_or_id]["from_bus"] == global_bus + else: + assert self.env.backend._grid.line.iloc[line_ex_id]["to_bus"] == global_bus + assert self.env.backend._grid.bus.loc[global_bus]["in_service"] + else: + assert not self.env.backend._grid.load.iloc[el_id]["in_service"] + if line_or_id is not None: + assert not self.env.backend._grid.line.iloc[line_or_id]["in_service"] + else: + assert not self.env.backend._grid.line.iloc[line_ex_id]["in_service"] + topo_vect = self.env.backend._get_topo_vect() + assert topo_vect[cls.load_pos_topo_vect[el_id]] == new_bus, f"{topo_vect[cls.load_pos_topo_vect[el_id]]} vs {new_bus}" + + def test_move_gen(self): + cls = type(self.env) + res = self._aux_find_sub(self.env, cls.GEN_COL) + if res is None: + raise RuntimeError(f"Cannot carry the test 'test_move_gen' as " + "there are no suitable subastation in your grid.") + (sub_id, el_id, line_or_id, line_ex_id) = res + for new_bus in self.list_loc_bus: + if line_or_id is not None: + act = self.env.action_space({"set_bus": {"generators_id": [(el_id, new_bus)], "lines_or_id": [(line_or_id, new_bus)]}}) + else: + act = self.env.action_space({"set_bus": {"generators_id": [(el_id, new_bus)], "lines_ex_id": [(line_ex_id, new_bus)]}}) + bk_act = self.env._backend_action_class() + bk_act += act + self.env.backend.apply_action(bk_act) + global_bus = sub_id + (new_bus -1) * cls.n_sub + if new_bus >= 1: + assert self.env.backend._grid.gen.iloc[el_id]["bus"] == global_bus + if line_or_id is not None: + assert self.env.backend._grid.line.iloc[line_or_id]["from_bus"] == global_bus + else: + assert self.env.backend._grid.line.iloc[line_ex_id]["to_bus"] == global_bus + assert self.env.backend._grid.bus.loc[global_bus]["in_service"] + else: + assert not self.env.backend._grid.gen.iloc[el_id]["in_service"] + if line_or_id is not None: + assert not self.env.backend._grid.line.iloc[line_or_id]["in_service"] + else: + assert not self.env.backend._grid.line.iloc[line_ex_id]["in_service"] + topo_vect = self.env.backend._get_topo_vect() + assert topo_vect[cls.gen_pos_topo_vect[el_id]] == new_bus, f"{topo_vect[cls.gen_pos_topo_vect[el_id]]} vs {new_bus}" + + def test_move_storage(self): + cls = type(self.env) + res = self._aux_find_sub(self.env, cls.STORAGE_COL) + if res is None: + raise RuntimeError(f"Cannot carry the test 'test_move_storage' as " + "there are no suitable subastation in your grid.") + (sub_id, el_id, line_or_id, line_ex_id) = res + for new_bus in self.list_loc_bus: + if line_or_id is not None: + act = self.env.action_space({"set_bus": {"storages_id": [(el_id, new_bus)], "lines_or_id": [(line_or_id, new_bus)]}}) + else: + act = self.env.action_space({"set_bus": {"storages_id": [(el_id, new_bus)], "lines_ex_id": [(line_ex_id, new_bus)]}}) + bk_act = self.env._backend_action_class() + bk_act += act + self.env.backend.apply_action(bk_act) + global_bus = sub_id + (new_bus -1) * cls.n_sub + if new_bus >= 1: + assert self.env.backend._grid.storage.iloc[el_id]["bus"] == global_bus + assert self.env.backend._grid.storage.iloc[el_id]["in_service"], f"storage should not be deactivated" + if line_or_id is not None: + assert self.env.backend._grid.line.iloc[line_or_id]["from_bus"] == global_bus + else: + assert self.env.backend._grid.line.iloc[line_ex_id]["to_bus"] == global_bus + assert self.env.backend._grid.bus.loc[global_bus]["in_service"] + else: + assert not self.env.backend._grid.storage.iloc[el_id]["in_service"], f"storage should be deactivated" + if line_or_id is not None: + assert not self.env.backend._grid.line.iloc[line_or_id]["in_service"] + else: + assert not self.env.backend._grid.line.iloc[line_ex_id]["in_service"] + topo_vect = self.env.backend._get_topo_vect() + assert topo_vect[cls.storage_pos_topo_vect[el_id]] == new_bus, f"{topo_vect[cls.storage_pos_topo_vect[el_id]]} vs {new_bus}" + + def test_move_line_or(self): + cls = type(self.env) + line_id = 0 + for new_bus in self.list_loc_bus: + act = self.env.action_space({"set_bus": {"lines_or_id": [(line_id, new_bus)]}}) + bk_act = self.env._backend_action_class() + bk_act += act + self.env.backend.apply_action(bk_act) + global_bus = cls.line_or_to_subid[line_id] + (new_bus -1) * cls.n_sub + if new_bus >= 1: + assert self.env.backend._grid.line.iloc[line_id]["from_bus"] == global_bus + 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 + 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}" + + def test_move_line_ex(self): + cls = type(self.env) + line_id = 0 + for new_bus in self.list_loc_bus: + act = self.env.action_space({"set_bus": {"lines_ex_id": [(line_id, new_bus)]}}) + bk_act = self.env._backend_action_class() + bk_act += act + self.env.backend.apply_action(bk_act) + global_bus = cls.line_ex_to_subid[line_id] + (new_bus -1) * cls.n_sub + if new_bus >= 1: + assert self.env.backend._grid.line.iloc[line_id]["to_bus"] == global_bus + 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 + 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}" + + def test_move_shunt(self): + cls = type(self.env) + res = self._aux_find_sub_shunt(self.env) + if res is None: + raise RuntimeError(f"Cannot carry the test 'test_move_load' as " + "there are no suitable subastation in your grid.") + (sub_id, el_id, line_or_id, line_ex_id) = res + for new_bus in self.list_loc_bus: + if line_or_id is not None: + act = self.env.action_space({"shunt": {"set_bus": [(el_id, new_bus)]}, "set_bus": {"lines_or_id": [(line_or_id, new_bus)]}}) + else: + act = self.env.action_space({"shunt": {"set_bus": [(el_id, new_bus)]}, "set_bus": {"lines_ex_id": [(line_ex_id, new_bus)]}}) + bk_act = self.env._backend_action_class() + bk_act += act + self.env.backend.apply_action(bk_act) + global_bus = sub_id + (new_bus -1) * cls.n_sub + if new_bus >= 1: + assert self.env.backend._grid.shunt.iloc[el_id]["bus"] == global_bus + if line_or_id is not None: + assert self.env.backend._grid.line.iloc[line_or_id]["from_bus"] == global_bus + else: + assert self.env.backend._grid.line.iloc[line_ex_id]["to_bus"] == global_bus + assert self.env.backend._grid.bus.loc[global_bus]["in_service"] + else: + assert not self.env.backend._grid.shunt.iloc[el_id]["in_service"] + if line_or_id is not None: + assert not self.env.backend._grid.line.iloc[line_or_id]["in_service"] + else: + assert not self.env.backend._grid.line.iloc[line_ex_id]["in_service"] + + def test_check_kirchoff(self): + cls = type(self.env) + res = self._aux_find_sub(self.env, cls.LOA_COL) + if res is None: + raise RuntimeError("Cannot carry the test 'test_move_load' as " + "there are no suitable subastation in your grid.") + (sub_id, el_id, line_or_id, line_ex_id) = res + for new_bus in self.list_loc_bus: + if new_bus <= -1: + continue + if line_or_id is not None: + act = self.env.action_space({"set_bus": {"loads_id": [(el_id, new_bus)], "lines_or_id": [(line_or_id, new_bus)]}}) + else: + act = self.env.action_space({"set_bus": {"loads_id": [(el_id, new_bus)], "lines_ex_id": [(line_ex_id, new_bus)]}}) + bk_act = self.env._backend_action_class() + bk_act += act + self.env.backend.apply_action(bk_act) + conv, maybe_exc = self.env.backend.runpf() + assert conv, f"error : {maybe_exc}" + p_subs, q_subs, p_bus, q_bus, diff_v_bus = self.env.backend.check_kirchoff() + # assert laws are met + assert np.abs(p_subs).max() <= 1e-5, f"error for busbar {new_bus}: {np.abs(p_subs).max():.2e}" + assert np.abs(q_subs).max() <= 1e-5, f"error for busbar {new_bus}: {np.abs(q_subs).max():.2e}" + assert np.abs(p_bus).max() <= 1e-5, f"error for busbar {new_bus}: {np.abs(p_bus).max():.2e}" + assert np.abs(q_bus).max() <= 1e-5, f"error for busbar {new_bus}: {np.abs(q_bus).max():.2e}" + assert np.abs(diff_v_bus).max() <= 1e-5, f"error for busbar {new_bus}: {np.abs(diff_v_bus).max():.2e}" + + +class TestPandapowerBackend_1busbar(TestPandapowerBackend_3busbars): + def get_nb_bus(self): + return 1 + + +class TestObservation_3busbars(unittest.TestCase): + def get_nb_bus(self): + return 3 + + def get_env_nm(self): + return "educ_case14_storage" + + def get_reset_kwargs(self) -> dict: + return dict(seed=0, options={"time serie id": 0}) + + def setUp(self) -> None: + with warnings.catch_warnings(): + warnings.filterwarnings("ignore") + self.env = grid2op.make(self.get_env_nm(), + backend=PandaPowerBackend(), + action_class=CompleteAction, + test=True, + n_busbar=self.get_nb_bus(), + _add_to_name=type(self).__name__ + f'_{self.get_nb_bus()}') + param = self.env.parameters + param.NB_TIMESTEP_COOLDOWN_SUB = 0 + param.NB_TIMESTEP_COOLDOWN_LINE = 0 + param.MAX_LINE_STATUS_CHANGED = 99999 + param.MAX_SUB_CHANGED = 99999 + self.env.change_parameters(param) + self.env.change_forecast_parameters(param) + self.env.reset(**self.get_reset_kwargs()) + self.list_loc_bus = list(range(1, type(self.env).n_busbar_per_sub + 1)) + return super().setUp() + + def tearDown(self) -> None: + self.env.close() + return super().tearDown() + + def test_get_simulator(self): + obs = self.env.reset(**self.get_reset_kwargs()) + sim = obs.get_simulator() + assert type(sim.backend).n_busbar_per_sub == self.get_nb_bus() + res = TestPandapowerBackend_3busbars._aux_find_sub(self.env, type(self.env).LOA_COL) + if res is None: + raise RuntimeError(f"Cannot carry the test 'test_get_simulator' as " + "there are no suitable subastation in your grid.") + (sub_id, el_id, line_or_id, line_ex_id) = res + for new_bus in self.list_loc_bus: + if line_or_id is not None: + act = self.env.action_space({"set_bus": {"loads_id": [(el_id, new_bus)], "lines_or_id": [(line_or_id, new_bus)]}}) + else: + act = self.env.action_space({"set_bus": {"loads_id": [(el_id, new_bus)], "lines_ex_id": [(line_ex_id, new_bus)]}}) + sim2 = sim.predict(act) + global_bus = sub_id + (new_bus -1) * type(self.env).n_sub + assert sim2.backend._grid.load["bus"].iloc[el_id] == global_bus + + def _aux_build_act(self, res, new_bus, el_keys): + """res: output of TestPandapowerBackend_3busbars._aux_find_sub""" + if res is None: + raise RuntimeError(f"Cannot carry the test as " + "there are no suitable subastation in your grid.") + (sub_id, el_id, line_or_id, line_ex_id) = res + if line_or_id is not None: + act = self.env.action_space({"set_bus": {el_keys: [(el_id, new_bus)], "lines_or_id": [(line_or_id, new_bus)]}}) + else: + act = self.env.action_space({"set_bus": {el_keys: [(el_id, new_bus)], "lines_ex_id": [(line_ex_id, new_bus)]}}) + return act + + @staticmethod + def _aux_aux_build_act(env, res, new_bus, el_keys): + """res: output of TestPandapowerBackend_3busbars._aux_find_sub""" + if res is None: + raise RuntimeError(f"Cannot carry the test as " + "there are no suitable subastation in your grid.") + (sub_id, el_id, line_or_id, line_ex_id) = res + if line_or_id is not None: + act = env.action_space({"set_bus": {el_keys: [(el_id, new_bus)], "lines_or_id": [(line_or_id, new_bus)]}}) + else: + act = env.action_space({"set_bus": {el_keys: [(el_id, new_bus)], "lines_ex_id": [(line_ex_id, new_bus)]}}) + return act + + def test_get_forecasted_env(self): + obs = self.env.reset(**self.get_reset_kwargs()) + for_env = obs.get_forecast_env() + assert type(for_env).n_busbar_per_sub == self.get_nb_bus() + for_obs = for_env.reset() + assert type(for_obs).n_busbar_per_sub == self.get_nb_bus() + res = TestPandapowerBackend_3busbars._aux_find_sub(self.env, type(self.env).LOA_COL) + (sub_id, el_id, line_or_id, line_ex_id) = res + for new_bus in self.list_loc_bus: + for_env = obs.get_forecast_env() + act = self._aux_build_act(res, new_bus, "loads_id") + sim_obs, sim_r, sim_d, sim_info = for_env.step(act) + assert not sim_d, f"{sim_info['exception']}" + assert sim_obs.load_bus[el_id] == new_bus, f"{sim_obs.load_bus[el_id]} vs {new_bus}" + + def test_add(self): + obs = self.env.reset(**self.get_reset_kwargs()) + res = TestPandapowerBackend_3busbars._aux_find_sub(self.env, type(self.env).LOA_COL) + (sub_id, el_id, line_or_id, line_ex_id) = res + for new_bus in self.list_loc_bus: + act = self._aux_build_act(res, new_bus, "loads_id") + obs_pus_act = obs + act + assert obs_pus_act.load_bus[el_id] == new_bus, f"{obs_pus_act.load_bus[el_id]} vs {new_bus}" + + def test_simulate(self): + obs = self.env.reset(**self.get_reset_kwargs()) + res = TestPandapowerBackend_3busbars._aux_find_sub(self.env, type(self.env).LOA_COL) + (sub_id, el_id, line_or_id, line_ex_id) = res + for new_bus in self.list_loc_bus: + act = self._aux_build_act(res, new_bus, "loads_id") + sim_obs, sim_r, sim_d, sim_info = obs.simulate(act) + assert not sim_d, f"{sim_info['exception']}" + assert sim_obs.load_bus[el_id] == new_bus, f"{sim_obs.load_bus[el_id]} vs {new_bus}" + + def test_action_space_get_back_to_ref_state(self): + """test the :func:`grid2op.Action.SerializableActionSpace.get_back_to_ref_state` + when 3 busbars which could not be tested without observation""" + obs = self.env.reset(**self.get_reset_kwargs()) + res = TestPandapowerBackend_3busbars._aux_find_sub(self.env, type(self.env).LOA_COL) + (sub_id, el_id, line_or_id, line_ex_id) = res + for new_bus in self.list_loc_bus: + if new_bus == 1: + # nothing to do if everything is moved to bus 1 + continue + act = self._aux_build_act(res, new_bus, "loads_id") + obs, reward, done, info = self.env.step(act) + assert not done + acts = self.env.action_space.get_back_to_ref_state(obs) + assert "substation" in acts + assert len(acts["substation"]) == 1 + act_to_ref = acts["substation"][0] + assert act_to_ref.load_set_bus[el_id] == 1 + if line_or_id is not None: + assert act_to_ref.line_or_set_bus[line_or_id] == 1 + if line_ex_id is not None: + assert act_to_ref.line_ex_set_bus[line_ex_id] == 1 + + def test_connectivity_matrix(self): + cls = type(self.env) + obs = self.env.reset(**self.get_reset_kwargs()) + res = TestPandapowerBackend_3busbars._aux_find_sub(self.env, type(self.env).LOA_COL) + (sub_id, el_id, line_or_id, line_ex_id) = res + for new_bus in self.list_loc_bus: + act = self._aux_build_act(res, new_bus, "loads_id") + obs, reward, done, info = self.env.step(act) + assert not done + assert not info["exception"], "there should not have any exception (action should be legal)" + conn_mat = obs.connectivity_matrix() + assert conn_mat.shape == (cls.dim_topo, cls.dim_topo) + if new_bus == 1: + min_sub = np.sum(cls.sub_info[:sub_id]) + max_sub = min_sub + cls.sub_info[sub_id] + assert (conn_mat[min_sub:max_sub, min_sub:max_sub] == 1.).all() + else: + el_topov = cls.load_pos_topo_vect[el_id] + line_pos_topov = cls.line_or_pos_topo_vect[line_or_id] if line_or_id is not None else cls.line_ex_pos_topo_vect[line_ex_id] + line_pos_topo_other = cls.line_ex_pos_topo_vect[line_or_id] if line_or_id is not None else cls.line_or_pos_topo_vect[line_ex_id] + assert conn_mat[el_topov, line_pos_topov] == 1. + assert conn_mat[line_pos_topov, el_topov] == 1. + for el in range(cls.dim_topo): + if el == line_pos_topov: + continue + if el == el_topov: + continue + if el == line_pos_topo_other: + # other side of the line is connected to it + continue + assert conn_mat[el_topov, el] == 0., f"error for {new_bus}: ({el_topov}, {el}) appears to be connected: {conn_mat[el_topov, el]}" + assert conn_mat[el, el_topov] == 0., f"error for {new_bus}: ({el}, {el_topov}) appears to be connected: {conn_mat[el, el_topov]}" + assert conn_mat[line_pos_topov, el] == 0., f"error for {new_bus}: ({line_pos_topov}, {el}) appears to be connected: {conn_mat[line_pos_topov, el]}" + assert conn_mat[el, line_pos_topov] == 0., f"error for {new_bus}: ({el}, {line_pos_topov}) appears to be connected: {conn_mat[el, line_pos_topov]}" + + def test_bus_connectivity_matrix(self): + cls = type(self.env) + obs = self.env.reset(**self.get_reset_kwargs()) + res = TestPandapowerBackend_3busbars._aux_find_sub(self.env, type(self.env).LOA_COL) + (sub_id, el_id, line_or_id, line_ex_id) = res + for new_bus in self.list_loc_bus: + act = self._aux_build_act(res, new_bus, "loads_id") + obs, reward, done, info = self.env.step(act) + assert not done + assert not info["exception"], "there should not have any exception (action should be legal)" + conn_mat, (lor_ind, lex_ind) = obs.bus_connectivity_matrix(return_lines_index=True) + if new_bus == 1: + assert conn_mat.shape == (cls.n_sub, cls.n_sub) + else: + assert conn_mat.shape == (cls.n_sub + 1, cls.n_sub + 1) + new_bus_id = lor_ind[line_or_id] if line_or_id else lex_ind[line_ex_id] + bus_other = lex_ind[line_or_id] if line_or_id else lor_ind[line_ex_id] + assert conn_mat[new_bus_id, bus_other] == 1. + assert conn_mat[bus_other, new_bus_id] == 1. + assert conn_mat[new_bus_id, sub_id] == 0. + assert conn_mat[sub_id, new_bus_id] == 0. + + def test_flow_bus_matrix(self): + cls = type(self.env) + obs = self.env.reset(**self.get_reset_kwargs()) + res = TestPandapowerBackend_3busbars._aux_find_sub(self.env, type(self.env).LOA_COL) + (sub_id, el_id, line_or_id, line_ex_id) = res + for new_bus in self.list_loc_bus: + act = self._aux_build_act(res, new_bus, "loads_id") + obs, reward, done, info = self.env.step(act) + assert not done + assert not info["exception"], "there should not have any exception (action should be legal)" + conn_mat, (load_bus, prod_bus, stor_bus, lor_ind, lex_ind) = obs.flow_bus_matrix() + if new_bus == 1: + assert conn_mat.shape == (cls.n_sub, cls.n_sub) + else: + assert conn_mat.shape == (cls.n_sub + 1, cls.n_sub + 1) + new_bus_id = lor_ind[line_or_id] if line_or_id else lex_ind[line_ex_id] + bus_other = lex_ind[line_or_id] if line_or_id else lor_ind[line_ex_id] + assert conn_mat[new_bus_id, bus_other] != 0. # there are some flows from these 2 buses + assert conn_mat[bus_other, new_bus_id] != 0. # there are some flows from these 2 buses + assert conn_mat[new_bus_id, sub_id] == 0. + assert conn_mat[sub_id, new_bus_id] == 0. + + def test_get_energy_graph(self): + cls = type(self.env) + obs = self.env.reset(**self.get_reset_kwargs()) + res = TestPandapowerBackend_3busbars._aux_find_sub(self.env, type(self.env).LOA_COL) + (sub_id, el_id, line_or_id, line_ex_id) = res + for new_bus in self.list_loc_bus: + act = self._aux_build_act(res, new_bus, "loads_id") + obs, reward, done, info = self.env.step(act) + assert not done + assert not info["exception"], "there should not have any exception (action should be legal)" + graph = obs.get_energy_graph() + if new_bus == 1: + assert len(graph.nodes) == cls.n_sub + continue + # if I end up here it's because new_bus >= 2 + assert len(graph.nodes) == cls.n_sub + 1 + new_bus_id = cls.n_sub # this bus has been added + bus_other = cls.line_ex_to_subid[line_or_id] if line_or_id else cls.line_or_to_subid[line_ex_id] + assert (new_bus_id, bus_other) in graph.edges + edge = graph.edges[(new_bus_id, bus_other)] + node = graph.nodes[new_bus_id] + assert node["local_bus_id"] == new_bus + assert node["global_bus_id"] == sub_id + (new_bus - 1) * cls.n_sub + if line_or_id is not None: + assert edge["bus_or"] == new_bus + assert edge["global_bus_or"] == sub_id + (new_bus - 1) * cls.n_sub + else: + assert edge["bus_ex"] == new_bus + assert edge["global_bus_ex"] == sub_id + (new_bus - 1) * cls.n_sub + + def test_get_elements_graph(self): + cls = type(self.env) + obs = self.env.reset(**self.get_reset_kwargs()) + res = TestPandapowerBackend_3busbars._aux_find_sub(self.env, type(self.env).LOA_COL) + (sub_id, el_id, line_or_id, line_ex_id) = res + for new_bus in self.list_loc_bus: + act = self._aux_build_act(res, new_bus, "loads_id") + obs, reward, done, info = self.env.step(act) + assert not done + assert not info["exception"], "there should not have any exception (action should be legal)" + graph = obs.get_elements_graph() + global_bus_id = sub_id + (new_bus - 1) * cls.n_sub + node_bus_id = graph.graph['bus_nodes_id'][global_bus_id] + node_load_id = graph.graph['load_nodes_id'][el_id] + node_line_id = graph.graph['line_nodes_id'][line_or_id] if line_or_id is not None else graph.graph['line_nodes_id'][line_ex_id] + node_load = graph.nodes[node_load_id] + node_line = graph.nodes[node_line_id] + assert len(graph.graph["bus_nodes_id"]) == cls.n_busbar_per_sub * cls.n_sub + + # check the bus + for node_id in graph.graph["bus_nodes_id"]: + assert "global_id" in graph.nodes[node_id], "key 'global_id' should be in the node" + if new_bus == 1: + for node_id in graph.graph["bus_nodes_id"][cls.n_sub:]: + assert not graph.nodes[node_id]["connected"], f"bus (global id {graph.nodes[node_id]['global_id']}) represented by node {node_id} should not be connected" + else: + for node_id in graph.graph["bus_nodes_id"][cls.n_sub:]: + if graph.nodes[node_id]['global_id'] != global_bus_id: + assert not graph.nodes[node_id]["connected"], f"bus (global id {graph.nodes[node_id]['global_id']}) represented by node {node_id} should not be connected" + else: + assert graph.nodes[node_id]["connected"], f"bus (global id {graph.nodes[node_id]['global_id']}) represented by node {node_id} should be connected" + + # check the load + edge_load_id = node_load["bus_node_id"] + assert node_load["local_bus"] == new_bus + assert node_load["global_bus"] == global_bus_id + assert (node_load_id, edge_load_id) in graph.edges + + # check lines + side = "or" if line_or_id is not None else "ex" + edge_line_id = node_line[f"bus_node_id_{side}"] + assert node_line[f"local_bus_{side}"] == new_bus + assert node_line[f"global_bus_{side}"] == global_bus_id + assert (node_line_id, edge_line_id) in graph.edges + + +class TestObservation_1busbar(TestObservation_3busbars): + def get_nb_bus(self): + return 1 + + +class TestEnv_3busbars(unittest.TestCase): + def get_nb_bus(self): + return 3 + + def get_env_nm(self): + return "educ_case14_storage" + + def get_reset_kwargs(self) -> dict: + return dict(seed=0, options={"time serie id": 0}) + + def setUp(self) -> None: + with warnings.catch_warnings(): + warnings.filterwarnings("ignore") + self.env = grid2op.make(self.get_env_nm(), + backend=PandaPowerBackend(), + action_class=CompleteAction, + test=True, + n_busbar=self.get_nb_bus(), + _add_to_name=type(self).__name__ + f'_{self.get_nb_bus()}') + param = self.env.parameters + param.NB_TIMESTEP_COOLDOWN_SUB = 0 + param.NB_TIMESTEP_COOLDOWN_LINE = 0 + param.MAX_LINE_STATUS_CHANGED = 99999 + param.MAX_SUB_CHANGED = 99999 + self.env.change_parameters(param) + self.env.change_forecast_parameters(param) + self.env.reset(**self.get_reset_kwargs()) + self.list_loc_bus = list(range(1, type(self.env).n_busbar_per_sub + 1)) + self.max_iter = 10 + return super().setUp() + + def tearDown(self) -> None: + self.env.close() + return super().tearDown() + + def test_go_to_end(self): + self.env.set_max_iter(self.max_iter) + obs = self.env.reset(**self.get_reset_kwargs()) + i = 0 + done = False + while not done: + obs, reward, done, info = self.env.step(self.env.action_space()) + i += 1 + assert i == 10, f"{i} instead of 10" + + def test_can_put_on_3(self): + self.env.set_max_iter(self.max_iter) + obs = self.env.reset(**self.get_reset_kwargs()) + res = TestPandapowerBackend_3busbars._aux_find_sub(self.env, type(self.env).LOA_COL) + (sub_id, el_id, line_or_id, line_ex_id) = res + act = TestObservation_3busbars._aux_aux_build_act(self.env, res, self.get_nb_bus(), "loads_id") + i = 0 + done = False + while not done: + if i == 0: + obs, reward, done, info = self.env.step(act) + else: + obs, reward, done, info = self.env.step(self.env.action_space()) + i += 1 + assert i == 10, f"{i} instead of 10" + + def test_can_move_from_3(self): + if self.get_nb_bus() <= 2: + self.skipTest("Need at leat two busbars") + self.env.set_max_iter(self.max_iter) + obs = self.env.reset(**self.get_reset_kwargs()) + res = TestPandapowerBackend_3busbars._aux_find_sub(self.env, type(self.env).LOA_COL) + (sub_id, el_id, line_or_id, line_ex_id) = res + act = TestObservation_3busbars._aux_aux_build_act(self.env, res, self.get_nb_bus(), "loads_id") + i = 0 + done = False + while not done: + if i == 0: + # do the action to set on a busbar 3 + obs, reward, done, info = self.env.step(act) + assert not done + assert not info["exception"] + elif i == 1: + # do the opposite action + dict_act = obs.get_back_to_ref_state() + assert "substation" in dict_act + li_act = dict_act["substation"] + assert len(li_act) == 1 + act = li_act[0] + obs, reward, done, info = self.env.step(act) + assert not done + assert not info["exception"] + else: + obs, reward, done, info = self.env.step(self.env.action_space()) + i += 1 + assert i == 10, f"{i} instead of 10" + + def _aux_alone_done(self, key="loads_id"): + if self.get_nb_bus() <= 2: + self.skipTest("Need at leat two busbars") + obs = self.env.reset(**self.get_reset_kwargs()) + act = self.env.action_space({"set_bus": {key: [(0, self.get_nb_bus())]}}) + obs, reward, done, info = self.env.step(act) + assert done + + def test_load_alone_done(self): + self._aux_alone_done("loads_id") + + def test_gen_alone_done(self): + self._aux_alone_done("generators_id") + + def test_simulate(self): + """test the obs.simulate(...) works with different number of busbars""" + obs = self.env.reset(**self.get_reset_kwargs()) + res = TestPandapowerBackend_3busbars._aux_find_sub(self.env, type(self.env).LOA_COL) + (sub_id, el_id, line_or_id, line_ex_id) = res + act = TestObservation_3busbars._aux_aux_build_act(self.env, res, self.get_nb_bus(), "loads_id") + sim_obs, sim_r, sim_d, sim_i = obs.simulate(act) + assert not sim_d + assert not sim_i["exception"] + + +class TestEnv_1busbar(TestEnv_3busbars): + def get_nb_bus(self): + return 1 + + +class TestGym_3busbars(unittest.TestCase): + """Test the environment can be converted to gym, with proper min / max + for all type of action / observation space + """ + def get_nb_bus(self): + return 3 + + def get_env_nm(self): + return "educ_case14_storage" + + def get_reset_kwargs(self) -> dict: + # seed has been tuned for the tests to pass + return dict(seed=self.seed, options={"time serie id": 0}) + + def setUp(self) -> None: + with warnings.catch_warnings(): + warnings.filterwarnings("ignore") + self.env = grid2op.make(self.get_env_nm(), + backend=PandaPowerBackend(), + action_class=CompleteAction, + test=True, + n_busbar=self.get_nb_bus(), + _add_to_name=type(self).__name__ + f'_{self.get_nb_bus()}') + param = self.env.parameters + param.NB_TIMESTEP_COOLDOWN_SUB = 0 + param.NB_TIMESTEP_COOLDOWN_LINE = 0 + param.MAX_LINE_STATUS_CHANGED = 9999999 + param.MAX_SUB_CHANGED = 99999999 + self.env.change_parameters(param) + self.env.change_forecast_parameters(param) + self.seed = 0 + self.env.reset(**self.get_reset_kwargs()) + self.list_loc_bus = list(range(1, type(self.env).n_busbar_per_sub + 1)) + self.max_iter = 10 + return super().setUp() + + def tearDown(self) -> None: + self.env.close() + return super().tearDown() + + def _aux_test_env(self, gym_env): + obs, info = gym_env.reset(**self.get_reset_kwargs()) + assert obs in gym_env.observation_space + act = gym_env.action_space.sample() + assert act in gym_env.action_space + obs, reward, done, truncated, info = gym_env.step(act) + if done: + print(gym_env.action_space.from_gym(act)) + print(info["exception"]) + assert not done + assert not truncated + assert obs in gym_env.observation_space + act = gym_env.action_space.sample() + assert act in gym_env.action_space + obs, reward, done, truncated, info = gym_env.step(act) + assert not done + assert not truncated + assert obs in gym_env.observation_space + + def test_gym_env(self): + gym_env = GymEnv(self.env) + self._aux_test_env(gym_env) + + def test_discrete_act(self): + gym_env = GymEnv(self.env) + gym_env.action_space.close() + with warnings.catch_warnings(): + warnings.filterwarnings("ignore") + gym_env.action_space = DiscreteActSpace(self.env.action_space) + self.seed = 5 + self._aux_test_env(gym_env) + gym_env.action_space.close() + gym_env.action_space = DiscreteActSpace(self.env.action_space, + attr_to_keep=('set_bus', )) + self.seed = 1 + self._aux_test_env(gym_env) + + def test_box_act(self): + gym_env = GymEnv(self.env) + gym_env.action_space.close() + with warnings.catch_warnings(): + warnings.filterwarnings("ignore") + gym_env.action_space = BoxGymActSpace(self.env.action_space) + self._aux_test_env(gym_env) + + def test_multidiscrete_act(self): + # BoxGymObsSpace, + gym_env = GymEnv(self.env) + gym_env.action_space.close() + with warnings.catch_warnings(): + warnings.filterwarnings("ignore") + gym_env.action_space = MultiDiscreteActSpace(self.env.action_space) + self._aux_test_env(gym_env) + gym_env.action_space.close() + gym_env.action_space = MultiDiscreteActSpace(self.env.action_space, + attr_to_keep=('set_bus', )) + self._aux_test_env(gym_env) + gym_env.action_space.close() + gym_env.action_space = MultiDiscreteActSpace(self.env.action_space, + attr_to_keep=('sub_set_bus', )) + # no seed below 1000 works, so I force illegal actions... + param = self.env.parameters + param.MAX_LINE_STATUS_CHANGED = 1 + param.MAX_SUB_CHANGED = 1 + gym_env.init_env.change_parameters(param) + gym_env.init_env.change_forecast_parameters(param) + self.seed = 1 + self._aux_test_env(gym_env) + gym_env.action_space.close() + # remove illegal actions for this test + param.MAX_LINE_STATUS_CHANGED = 99999 + param.MAX_SUB_CHANGED = 99999 + gym_env.init_env.change_parameters(param) + gym_env.init_env.change_forecast_parameters(param) + gym_env.action_space = MultiDiscreteActSpace(self.env.action_space, + attr_to_keep=('one_sub_set', )) + self.seed = 1 + self._aux_test_env(gym_env) + + def test_box_obs(self): + gym_env = GymEnv(self.env) + gym_env.observation_space.close() + with warnings.catch_warnings(): + warnings.filterwarnings("ignore") + gym_env.observation_space = BoxGymObsSpace(self.env.observation_space) + self._aux_test_env(gym_env) + + +class TestGym_1busbar(TestGym_3busbars): + def get_nb_bus(self): + return 1 + + +class TestRulesNbBus(unittest.TestCase): + """test the rules for the reco / deco of line works also when >= 3 busbars, + also ttests the act.get_impact()... + """ + def get_nb_bus(self): + return 3 + + def get_env_nm(self): + return "educ_case14_storage" + + def get_reset_kwargs(self) -> dict: + # seed has been tuned for the tests to pass + return dict(seed=self.seed, options={"time serie id": 0}) + + def setUp(self) -> None: + with warnings.catch_warnings(): + warnings.filterwarnings("ignore") + self.env = grid2op.make(self.get_env_nm(), + backend=PandaPowerBackend(), + action_class=CompleteAction, + test=True, + n_busbar=self.get_nb_bus(), + _add_to_name=type(self).__name__ + f'_{self.get_nb_bus()}') + self.seed = 0 + self.env.reset(**self.get_reset_kwargs()) + self.list_loc_bus = list(range(1, type(self.env).n_busbar_per_sub + 1)) + self.max_iter = 10 + return super().setUp() + + def tearDown(self) -> None: + self.env.close() + return super().tearDown() + + def _aux_get_disco_line(self, line_id, dn_act): + obs = self.env.reset(**self.get_reset_kwargs()) + obs, reward, done, info = self.env.step(self.env.action_space({"set_line_status": [(line_id, -1)]})) + obs, reward, done, info = self.env.step(dn_act) + obs, reward, done, info = self.env.step(dn_act) + obs, reward, done, info = self.env.step(dn_act) + assert obs.time_before_cooldown_line[line_id] == 0 + + def test_cooldowns(self): + """check the tables of https://grid2op.readthedocs.io/en/latest/action.html#note-on-powerline-status in order + """ + line_id = 0 + subor_id = type(self.env).line_or_to_subid[line_id] + subex_id = type(self.env).line_ex_to_subid[line_id] + dn_act = self.env.action_space() + + # first row + obs = self.env.reset(**self.get_reset_kwargs()) + obs, reward, done, info = self.env.step(self.env.action_space({"set_line_status": [(line_id, -1)]})) + assert obs.time_before_cooldown_line[line_id] == 3 + assert obs.time_before_cooldown_sub[subor_id] == 0 + assert obs.time_before_cooldown_sub[subex_id] == 0 + + # 2nd row + obs = self.env.reset(**self.get_reset_kwargs()) + obs, reward, done, info = self.env.step(self.env.action_space({"set_line_status": [(line_id, +1)]})) + assert obs.time_before_cooldown_line[line_id] == 3 + assert obs.time_before_cooldown_sub[subor_id] == 0 + assert obs.time_before_cooldown_sub[subex_id] == 0 + + # 3rd row + self._aux_get_disco_line(line_id, dn_act) + obs, reward, done, info = self.env.step(self.env.action_space({"set_line_status": [(line_id, -1)]})) + assert obs.time_before_cooldown_line[line_id] == 3 + assert obs.time_before_cooldown_sub[subor_id] == 0 + assert obs.time_before_cooldown_sub[subex_id] == 0 + + # 4th row + self._aux_get_disco_line(line_id, dn_act) + obs, reward, done, info = self.env.step(self.env.action_space({"set_line_status": [(line_id, +1)]})) + assert obs.time_before_cooldown_line[line_id] == 3 + assert obs.time_before_cooldown_sub[subor_id] == 0 + assert obs.time_before_cooldown_sub[subex_id] == 0 + + # 5th row + obs = self.env.reset(**self.get_reset_kwargs()) + obs, reward, done, info = self.env.step(self.env.action_space({"change_line_status": [line_id]})) + assert obs.time_before_cooldown_line[line_id] == 3 + assert obs.time_before_cooldown_sub[subor_id] == 0 + assert obs.time_before_cooldown_sub[subex_id] == 0 + + # 6th row + self._aux_get_disco_line(line_id, dn_act) + obs, reward, done, info = self.env.step(self.env.action_space({"change_line_status": [line_id]})) + assert obs.time_before_cooldown_line[line_id] == 3 + assert obs.time_before_cooldown_sub[subor_id] == 0 + assert obs.time_before_cooldown_sub[subex_id] == 0 + + # 7th + obs = self.env.reset(**self.get_reset_kwargs()) + obs, reward, done, info = self.env.step(self.env.action_space({"set_bus": {"lines_or_id": [(line_id, -1)]}})) + assert obs.time_before_cooldown_line[line_id] == 3 + assert obs.time_before_cooldown_sub[subor_id] == 0 + assert obs.time_before_cooldown_sub[subex_id] == 0 + + # 8th + self._aux_get_disco_line(line_id, dn_act) + obs, reward, done, info = self.env.step(self.env.action_space({"set_bus": {"lines_or_id": [(line_id, -1)]}})) + assert obs.time_before_cooldown_line[line_id] == 0 + assert obs.time_before_cooldown_sub[subor_id] == 3 + assert obs.time_before_cooldown_sub[subex_id] == 0 + + # 9th + obs = self.env.reset(**self.get_reset_kwargs()) + obs, reward, done, info = self.env.step(self.env.action_space({"set_bus": {"lines_or_id": [(line_id, 3)]}})) + assert obs.time_before_cooldown_line[line_id] == 0 + assert obs.time_before_cooldown_sub[subor_id] == 3 + assert obs.time_before_cooldown_sub[subex_id] == 0 + + # 10th + self._aux_get_disco_line(line_id, dn_act) + obs, reward, done, info = self.env.step(self.env.action_space({"set_bus": {"lines_or_id": [(line_id, 3)]}})) + assert obs.time_before_cooldown_line[line_id] == 3 + assert obs.time_before_cooldown_sub[subor_id] == 0 + assert obs.time_before_cooldown_sub[subex_id] == 0 + + # 11th and 12th => no "change bus" when nb_bus is not 2 + + +if __name__ == "__main__": + unittest.main() + \ No newline at end of file diff --git a/grid2op/typing_variables.py b/grid2op/typing_variables.py new file mode 100644 index 000000000..aa5c55c4d --- /dev/null +++ b/grid2op/typing_variables.py @@ -0,0 +1,28 @@ +# Copyright (c) 2019-2020, 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 typing import Dict, Literal, Any, Union + +#: type hints corresponding to the "info" part of the env.step return value +STEP_INFO_TYPING = Dict[Literal["disc_lines", + "is_illegal", + "is_ambiguous", + "is_dispatching_illegal", + "is_illegal_reco", + "reason_alarm_illegal", + "reason_alert_illegal", + "opponent_attack_line", + "opponent_attack_sub", + "exception", + "detailed_infos_for_cascading_failures", + "rewards", + "time_series_id"], + Any] + +#: type hints for the "options" flag of reset function +RESET_OPTIONS_TYPING = Union[Dict[Union[str, Literal["time serie id"]], Union[int, str]], None] diff --git a/grid2op/utils/l2rpn_idf_2023_scores.py b/grid2op/utils/l2rpn_idf_2023_scores.py index 307cf3881..6655de254 100644 --- a/grid2op/utils/l2rpn_idf_2023_scores.py +++ b/grid2op/utils/l2rpn_idf_2023_scores.py @@ -114,13 +114,13 @@ def __init__( score_names=score_names, add_nb_highres_sim=add_nb_highres_sim, ) - weights=np.array([weight_op_score,weight_assistant_score,weight_nres_score]) + weights=np.array([weight_op_score, weight_assistant_score, weight_nres_score]) total_weights = weights.sum() - if total_weights != 1.0: + if abs(total_weights - 1.0) >= 1e-8: raise Grid2OpException( 'The weights of each component of the score shall sum to 1' ) - if np.any(weights <0): + if np.any(weights < 0.): raise Grid2OpException( 'All weights should be positive' ) diff --git a/utils/make_release.py b/utils/make_release.py index 057f9342c..c91346dd1 100644 --- a/utils/make_release.py +++ b/utils/make_release.py @@ -84,7 +84,7 @@ def modify_and_push_docker(version, # grid2op version version)) # TODO re.search(reg_, "0.0.4-rc1").group("prerelease") -> rc1 (if regex_version is the official one) - if re.search(f".*\.(rc|pre|dev)[0-9]+$", version) is not None: + if re.search(f".*(\\.|-)(rc|pre|dev)[0-9]+$", version) is not None: is_prerelease = True print("This is a pre release, docker will NOT be pushed, github tag will NOT be made") time.sleep(2)