diff --git a/CHANGELOG.rst b/CHANGELOG.rst index bd3bc470..3d495038 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -40,6 +40,8 @@ Work kind of in progress Next release --------------------------------- +- TODO bug on maintenance starting at midnight (they are not correctly handled in the observation) + => cf script test_issue_616 - TODO Notebook for tf_agents - TODO Notebook for acme - TODO Notebook using "keras rl" (see https://keras.io/examples/rl/ppo_cartpole/) @@ -48,7 +50,8 @@ Next release - TODO jax everything that can be: create a simple env based on jax for topology manipulation, without redispatching or rules - TODO backend in jax, maybe ? - +- TODO done and truncated properly handled in gym_compat module (when game over + before the end it's probably truncated and not done) [1.10.3] - 2024-xx-yy ------------------------- @@ -65,10 +68,23 @@ Next release (because it should always have been like this) - [FIXED] a bug in the `MultiFolder` and `MultifolderWithCache` leading to the wrong computation of `max_iter` on some corner cases +- [FIXED] issue on `seed` and `MultifolderWithCache` which caused + https://github.com/rte-france/Grid2Op/issues/616 +- [FIXED] another issue with the seeding of `MultifolderWithCache`: the seed was not used + correctly on the cache data when calling `chronics_handler.reset` multiple times without + any changes - [ADDED] possibility to skip some step when calling `env.reset(..., options={"init ts": ...})` - [ADDED] possibility to limit the duration of an episode with `env.reset(..., options={"max step": ...})` - [ADDED] possibility to specify the "reset_options" used in `env.reset` when using the runner with `runner.run(..., reset_options=xxx)` +- [ADDED] the time series now are able to regenerate their "random" part + even when "cached" thanks to the addition of the `regenerate_with_new_seed` of the + `GridValue` class (in public API) +- [ADDED] `MultifolderWithCache` now supports `FromHandlers` time series generator +- [IMPROVED] the documentation on the `time series` folder. +- [IMPROVED] now the "maintenance from json" (*eg* the `JSONMaintenanceHandler` or the + `GridStateFromFileWithForecastsWithMaintenance`) can be customized with the day + of the week where the maintenance happens (key `maintenance_day_of_week`) [1.10.2] - 2024-05-27 ------------------------- diff --git a/docs/chronics.rst b/docs/chronics.rst index 8a13f567..1557ab07 100644 --- a/docs/chronics.rst +++ b/docs/chronics.rst @@ -54,26 +54,158 @@ come from the :class:`grid2op.GridValue` and are detailed in the :func:`GridValue.forecasts` method. -More control on the chronics +More control on the time series ------------------------------- We explained, in the description of the :class:`grid2op.Environment` in sections :ref:`environment-module-chronics-info` and following how to have more control on which chronics is used, with steps are used within a chronics etc. We will not detailed here again, please refer to this page for more information. -However, know that you can have a very detailed control on which chronics are used: +However, know that you can have a very detailed control on which time series using the `options` +kwargs of a call to `env.reset()` (or the `reset_otions` kwargs when calling the +`runner.run()`) : -- use `env.set_id(THE_CHRONIC_ID)` (see :func:`grid2op.Environment.Environment.set_id`) to set the id of the - chronics you want to use -- use `env.chronics_handler.set_filter(a_function)` (see :func:`grid2op.Chronics.GridValue.set_filter`) + +Use a specific time serie for an episode +******************************************* + +To use a specific time series for a given episode, you can use +`env.reset(options={"time serie id": THE_ID_YOU_WANT)`. + +For example: + +.. code-block:: python + + import grid2op + env_name = "l2rpn_case14_sandbox" + env = grid2op.make(env_name) + + # you can use an int: + obs = env.reset(options={"time serie id": 0}) + + # or the name of the folder (for most grid2op environment) + obs = env.reset(options={"time serie id": "0000"}) # for l2rpn_case14_sandbox + + # for say l2rpn_neurips_2020_track1 + # obs = env.reset(options={"time serie id": "Scenario_august_008"}) + + # for say l2rpn_idf_2023 + # obs = env.reset(options={"time serie id": "2035-04-23_7"}) + + +.. note:: + For oldest grid2op versions (please upgrade if that's the case) you needed to use: + `env.set_id(THE_CHRONIC_ID)` (see :func:`grid2op.Environment.Environment.set_id`) to set the id of the + chronics you want to use. + + +Skipping the initial few steps +******************************* + +Often the time series provided for an environment always start at the same date and time on +the same hour of the day and day of the week. It might not be ideal to learn controler +with such data or might "burn up" computation time during evaluation. + +To do that, you can use the `"init ts"` reset options, for example with: + +.. code-block:: python + + import grid2op + env_name = "l2rpn_case14_sandbox" + env = grid2op.make(env_name) + + # you can use an int: + obs = env.reset(options={"init ts": 12}) + + # obs will skip the first hour of the time series + # 12 steps is equivalent to 1h (5 mins per step in general) + + +.. note:: + + For oldest grid2op versions (please upgrade if that's the case) you needed to use: + `env.fast_forward_chronics(nb_time_steps)` + (see :func:`grid2op.Environment.BaseEnv.fast_forward_chronics`) to skip initial + few steps + of a given chronics. + + Please be aware that this "legacy" behaviour has some issues and is "less clear" + than the "init ts" above and it can have some weird combination with + `set_max_iter` for example. + + +Limit the maximum length of the current episode +************************************************* + +For most enviroment, the maximum duration of an episode is the equivalent of a week +(~2020 steps) or a month (~8100 steps) which might be too long for some usecase. + +Anyway, if you want to reduce it, you can now do it with the `"max step"` reset +option like this: + +.. code-block:: python + + import grid2op + env_name = "l2rpn_case14_sandbox" + env = grid2op.make(env_name) + + # you can use an int: + obs = env.reset(options={"max step": 2*288}) + + # the maximum duration of the episode is now 2*288 steps + # the equivalent of two days + +.. note:: + + For oldest grid2op versions (please upgrade if that's the case) you needed to use: + `env.chronics_handler.set_max_iter(nb_max_iter)` + (see :func:`grid2op.Chronics.ChronicsHandler.set_max_iter`) to limit the number + of steps within an episode. + + Please be aware that this "legacy" behaviour has some issues and is "less clear" + than the "init ts" above and it can have some weird combination with + `fast_forward_chronics` for example. + +Discard some time series from the existing folder +************************************************** + +The folder containing the time series for a given grid2op environment often contains +dozens (thousands sometimes) different time series. + +You might want to use only part of them at some point (whether it's some for training and some +for validation and test, or some for training an agent on a process and some to train the +same agent on another process etc.) + +Anyway, if you want to do this (on the majority of released environments) you can do it +thanks to the `env.chronics_handler.set_filter(a_function)`. + +For example: + +.. code-block:: python + + import re + import grid2op + env_name = "l2rpn_case14_sandbox" + env = grid2op.make(env_name) + + def keep_only_some_ep(chron_name): + return re.match(r".*00.*", chron_name) is not None + + env.chronics_handler.set_filter(keep_only_some_ep) + li_episode_kept = env.chronics_handler.reset() + + +.. note:: + For oldest grid2op versions (please upgrade if that's the case) you needed to use: + use `env.chronics_handler.set_filter(a_function)` (see :func:`grid2op.Chronics.GridValue.set_filter`) to only use certain chronics + + - use `env.chronics_handler.sample_next_chronics(probas)` (see :func:`grid2op.Chronics.GridValue.sample_next_chronics`) to draw at random some chronics -- use `env.fast_forward_chronics(nb_time_steps)` - (see :func:`grid2op.Environment.BaseEnv.fast_forward_chronics`) to skip initial number of steps - of a given chronics -- use `env.chronics_handler.set_max_iter(nb_max_iter)` - (see :func:`grid2op.Chronics.ChronicsHandler.set_max_iter`) to limit the number of steps within an episode + +Performance gain (throughput) +******************************** Chosing the right chronics can also lead to some large advantage in terms of computation time. This is particularly true if you want to benefit the most from HPC for example. More detailed is given in the diff --git a/grid2op/Chronics/GSFFWFWM.py b/grid2op/Chronics/GSFFWFWM.py index 28a0bf6f..8ab2c1f2 100644 --- a/grid2op/Chronics/GSFFWFWM.py +++ b/grid2op/Chronics/GSFFWFWM.py @@ -108,6 +108,14 @@ def initialize( self.max_daily_number_per_month_maintenance = dict_[ "max_daily_number_per_month_maintenance" ] + + if "maintenance_day_of_week" in dict_: + self.maintenance_day_of_week = [int(el) for el in dict_[ + "maintenance_day_of_week" + ]] + else: + self.maintenance_day_of_week = np.arange(5) + super().initialize( order_backend_loads, order_backend_prods, @@ -133,7 +141,6 @@ def _sample_maintenance(self): ######## # new method to introduce generated maintenance self.maintenance = self._generate_maintenance() # - ########## # same as before in GridStateFromFileWithForecasts GridStateFromFileWithForecastsWithMaintenance._fix_maintenance_format(self) @@ -171,7 +178,12 @@ def _generate_matenance_static(name_line, daily_proba_per_month_maintenance, max_daily_number_per_month_maintenance, space_prng, + maintenance_day_of_week=None ): + if maintenance_day_of_week is None: + # new in grid2op 1.10.3 + maintenance_day_of_week = np.arange(5) + # define maintenance dataframe with size (nbtimesteps,nlines) columnsNames = name_line nbTimesteps = n_ @@ -203,8 +215,6 @@ def _generate_matenance_static(name_line, datelist = datelist[:-1] n_lines_maintenance = len(line_to_maintenance) - - _24_h = timedelta(seconds=86400) nb_rows = int(86400 / time_interval.total_seconds()) selected_rows_beg = int( maintenance_starting_hour * 3600 / time_interval.total_seconds() @@ -220,7 +230,7 @@ def _generate_matenance_static(name_line, maxDailyMaintenance = -1 for nb_day_since_beg, this_day in enumerate(datelist): dayOfWeek = this_day.weekday() - if dayOfWeek < 5: # only maintenance starting on working days + if dayOfWeek in maintenance_day_of_week: month = this_day.month maintenance_me = np.zeros((nb_rows, nb_line_maint)) @@ -279,5 +289,9 @@ def _generate_maintenance(self): self.maintenance_ending_hour, self.daily_proba_per_month_maintenance, self.max_daily_number_per_month_maintenance, - self.space_prng + self.space_prng, + self.maintenance_day_of_week ) + + def regenerate_with_new_seed(self): + self._sample_maintenance() diff --git a/grid2op/Chronics/fromChronix2grid.py b/grid2op/Chronics/fromChronix2grid.py index 2831f8d9..9c684340 100644 --- a/grid2op/Chronics/fromChronix2grid.py +++ b/grid2op/Chronics/fromChronix2grid.py @@ -309,4 +309,12 @@ def next_chronics(self): GridStateFromFileWithForecastsWithMaintenance._fix_maintenance_format(self) self.check_validity(backend=None) + + def regenerate_with_new_seed(self): + raise ChronicsError("You should not 'cache' the data coming from the " + "`FromChronix2grid`, which is probably why you ended " + "up calling this function. If you want to generate data " + "'on the fly' please do not use the `MultiFolder` or " + "`MultiFolderWithCache` `chronics_class` when making your " + "environment.") \ No newline at end of file diff --git a/grid2op/Chronics/gridValue.py b/grid2op/Chronics/gridValue.py index e49c6bb5..44cc2cb5 100644 --- a/grid2op/Chronics/gridValue.py +++ b/grid2op/Chronics/gridValue.py @@ -856,3 +856,21 @@ def cleanup_action_space(self): """ self.__action_space = None # NB the action space is not closed as it is NOT own by this class + + def regenerate_with_new_seed(self): + """ + INTERNAL this function is called by some classes (*eg* :class:`MultifolderWithCache`) + when a new seed has been set. + + For example, if you use some 'chronics' that generate part of them randomly (*eg* + :class:`GridStateFromFileWithForecastsWithMaintenance`) they need to be aware of this + so that a reset actually update the seeds. + + This is closely related to issue https://github.com/rte-france/Grid2Op/issues/616 + + .. danger:: + This function should be called only once (not 0, not twice) after a "seed" function has been set. + Otherwise results might not be fully reproducible. + + """ + pass diff --git a/grid2op/Chronics/handlers/baseHandler.py b/grid2op/Chronics/handlers/baseHandler.py index d4acf1d6..329e06f7 100644 --- a/grid2op/Chronics/handlers/baseHandler.py +++ b/grid2op/Chronics/handlers/baseHandler.py @@ -494,3 +494,14 @@ def get_init_dict_action(self) -> Union[dict, None]: action space. """ raise NotImplementedError() + + def regenerate_with_new_seed(self): + """This function is called in case of data being "cached" (for example using the + :class:`grid2op.Chronics.MultifolderWithCache`) + + In this case, the data in cache needs to be updated if the seed has changed since + the time they have been added to it. + + If your handler has some random part, we recommend you to implement this function. + Otherwise feel free to ignore it""" + pass diff --git a/grid2op/Chronics/handlers/jsonMaintenanceHandler.py b/grid2op/Chronics/handlers/jsonMaintenanceHandler.py index 27d2eef7..3b891ab2 100644 --- a/grid2op/Chronics/handlers/jsonMaintenanceHandler.py +++ b/grid2op/Chronics/handlers/jsonMaintenanceHandler.py @@ -63,7 +63,8 @@ def __init__(self, self.n_line = None # used in one of the GridStateFromFileWithForecastsWithMaintenance functions self._duration_episode_default = _duration_episode_default self.current_index = 0 - + self._order_backend_arrays = None + def get_maintenance_time_1d(self, maintenance): return GridValue.get_maintenance_time_1d(maintenance) @@ -82,7 +83,8 @@ def _create_maintenance_arrays(self, current_datetime): self.dict_meta_data["maintenance_ending_hour"], self.dict_meta_data["daily_proba_per_month_maintenance"], self.dict_meta_data["max_daily_number_per_month_maintenance"], - self.space_prng + self.space_prng, + self.dict_meta_data["maintenance_day_of_week"] if "maintenance_day_of_week" in self.dict_meta_data else None ) GridStateFromFileWithForecastsWithMaintenance._fix_maintenance_format(self) @@ -128,4 +130,8 @@ def _clear(self): def done(self): # maintenance can be generated on the fly so they are never "done" - return False \ No newline at end of file + return False + + def regenerate_with_new_seed(self): + if self.dict_meta_data is not None: + self._create_maintenance_arrays(self.init_datetime) diff --git a/grid2op/Chronics/handlers/noisyForecastHandler.py b/grid2op/Chronics/handlers/noisyForecastHandler.py index e047c927..8fb4cc76 100644 --- a/grid2op/Chronics/handlers/noisyForecastHandler.py +++ b/grid2op/Chronics/handlers/noisyForecastHandler.py @@ -212,3 +212,7 @@ def forecast(self, res *= self._env_loss_ratio(inj_dict_env) # TODO ramps, pmin, pmax ! return res.astype(dt_float) if res is not None else None + + def regenerate_with_new_seed(self): + # there is nothing to do for this handler as things are generated "on the fly" + pass \ No newline at end of file diff --git a/grid2op/Chronics/multiFolder.py b/grid2op/Chronics/multiFolder.py index be2d360b..cf34829c 100644 --- a/grid2op/Chronics/multiFolder.py +++ b/grid2op/Chronics/multiFolder.py @@ -394,6 +394,17 @@ def reset(self): self._order = np.array(self._order) return self.subpaths[self._order] + def _get_nex_data(self, this_path): + res = self.gridvalueClass( + time_interval=self.time_interval, + sep=self.sep, + path=this_path, + max_iter=self.max_iter, + chunk_size=self.chunk_size, + **self._kwargs + ) + return res + def initialize( self, order_backend_loads, @@ -419,14 +430,7 @@ def initialize( id_scenario = self._order[self._prev_cache_id] this_path = self.subpaths[id_scenario] - self.data = self.gridvalueClass( - time_interval=self.time_interval, - sep=self.sep, - path=this_path, - max_iter=self.max_iter, - chunk_size=self.chunk_size, - **self._kwargs - ) + self.data = self._get_nex_data(this_path) if self.seed is not None: max_int = np.iinfo(dt_int).max seed_chronics = self.space_prng.randint(max_int) diff --git a/grid2op/Chronics/multifolderWithCache.py b/grid2op/Chronics/multifolderWithCache.py index a7f09ea0..43684284 100644 --- a/grid2op/Chronics/multifolderWithCache.py +++ b/grid2op/Chronics/multifolderWithCache.py @@ -7,10 +7,12 @@ # This file is part of Grid2Op, Grid2Op a testbed platform to model sequential decision making in power systems. import numpy as np from datetime import timedelta, datetime +import warnings from grid2op.dtypes import dt_int from grid2op.Chronics.multiFolder import Multifolder from grid2op.Chronics.gridStateFromFile import GridStateFromFile +from grid2op.Chronics.time_series_from_handlers import FromHandlers from grid2op.Exceptions import ChronicsError @@ -140,12 +142,18 @@ def __init__( ) self._cached_data = None self.cache_size = 0 - if not issubclass(self.gridvalueClass, GridStateFromFile): + if not (issubclass(self.gridvalueClass, GridStateFromFile) or + issubclass(self.gridvalueClass, FromHandlers)): raise RuntimeError( 'MultifolderWithCache does not work when "gridvalueClass" does not inherit from ' '"GridStateFromFile".' ) + if issubclass(self.gridvalueClass, FromHandlers): + warnings.warn("You use caching with handler data. This is possible but " + "might be a bit risky especially if your handlers are " + "heavily 'random' and you want fully reproducible results.") self.__i = 0 + self._cached_seeds = None def _default_filter(self, x): """ @@ -162,6 +170,11 @@ def reset(self): Rebuilt the cache as if it were built from scratch. This call might take a while to process. + This means that current data in cache will be discarded and that new data will + most likely be read from the hard drive. + + This might take a while. + .. danger:: You NEED to call this function (with `env.chronics_handler.reset()`) if you use the `MultiFolderWithCache` class in your experiments. @@ -180,16 +193,10 @@ def reset(self): for i in self._order: # everything in "_order" need to be put in cache path = self.subpaths[i] - data = self.gridvalueClass( - time_interval=self.time_interval, - sep=self.sep, - path=path, - max_iter=self.max_iter, - chunk_size=None, - ) - if self.seed_used is not None: - seed_chronics = self.space_prng.randint(max_int) - data.seed(seed_chronics) + data = self._get_nex_data(path) + + if self._cached_seeds is not None: + data.seed(self._cached_seeds[i]) data.initialize( self._order_backend_loads, @@ -198,6 +205,10 @@ def reset(self): self._order_backend_subs, self._names_chronics_to_backend, ) + + if self._cached_seeds is not None: + data.regenerate_with_new_seed() + self._cached_data[i] = data self.cache_size += 1 if self.action_space is not None: @@ -233,12 +244,15 @@ def initialize( self.n_load = len(order_backend_loads) self.n_line = len(order_backend_lines) if self._cached_data is None: - # initialize the cache + # initialize the cache of this MultiFolder self.reset() id_scenario = self._order[self._prev_cache_id] self.data = self._cached_data[id_scenario] self.data.next_chronics() + if self.seed_used is not None and self.data.seed_used != self._cached_seeds[id_scenario]: + self.data.seed(self._cached_seeds[id_scenario]) + self.data.regenerate_with_new_seed() self._max_iter = self.data.max_iter @property @@ -261,6 +275,15 @@ def seed(self, seed : int): (which has an impact for example on :func:`MultiFolder.sample_next_chronics`) and each data present in the cache. + .. warning:: + Before grid2op version 1.10.3 this function did not fully ensured + reproducible experiments (the cache was not update with the new seed) + + For grid2op 1.10.3 and after, this function might trigger some modification + in the cached data (calling :func:`GridValue.seed` and then + :func:`GridValue.regenerate_with_new_seed`). It might take a while if the cache + is large. + Parameters ---------- seed : int @@ -268,12 +291,15 @@ def seed(self, seed : int): """ res = super().seed(seed) max_int = np.iinfo(dt_int).max + self._cached_seeds = np.empty(shape=self._order.shape, dtype=dt_int) for i in self._order: data = self._cached_data[i] + seed_ts = self.space_prng.randint(max_int) + self._cached_seeds[i] = seed_ts if data is None: continue - seed_ts = self.space_prng.randint(max_int) data.seed(seed_ts) + data.regenerate_with_new_seed() return res def load_next(self): @@ -285,9 +311,66 @@ def load_next(self): return super().load_next() def set_filter(self, filter_fun): + """ + Assign a filtering function to remove some chronics from the next time a call to "reset_cache" is called. + + **NB** filter_fun is applied to all element of :attr:`Multifolder.subpaths`. If ``True`` then it will + be put in cache, if ``False`` this data will NOT be put in the cache. + + **NB** this has no effect until :attr:`Multifolder.reset` is called. + + + .. danger:: + Calling this function cancels the previous seed used. If you use `env.seed` + or `env.chronics_handler.seed` before then you need to + call it again after otherwise it has no effect. + + Parameters + ---------- + filter_fun : _type_ + _description_ + + Examples + -------- + Let's assume in your chronics, the folder names are "Scenario_august_dummy", and + "Scenario_february_dummy". For the sake of the example, we want the environment to loop + only through the month of february, because why not. Then we can do the following: + + .. code-block:: python + + import re + import grid2op + env = grid2op.make("l2rpn_neurips_2020_track1", test=True) # don't add "test=True" if + # you don't want to perform a test. + + # check at which month will belong each observation + for i in range(10): + obs = env.reset() + print(obs.month) + # it always alternatively prints "8" (if chronics if from august) or + # "2" if chronics is from february) + + # to see where the chronics are located + print(env.chronics_handler.subpaths) + + # keep only the month of february + env.chronics_handler.set_filter(lambda path: re.match(".*february.*", path) is not None) + env.chronics_handler.reset() # if you don't do that it will not have any effect + + for i in range(10): + obs = env.reset() + print(obs.month) + # it always prints "2" (representing february) + + Returns + ------- + _type_ + _description_ + """ self.__nb_reset_called = 0 self.__nb_step_called = 0 self.__nb_init_called = 0 + self._cached_seeds = None return super().set_filter(filter_fun) def get_kwargs(self, dict_): diff --git a/grid2op/Chronics/time_series_from_handlers.py b/grid2op/Chronics/time_series_from_handlers.py index 99715281..646cf3de 100644 --- a/grid2op/Chronics/time_series_from_handlers.py +++ b/grid2op/Chronics/time_series_from_handlers.py @@ -560,3 +560,7 @@ def get_init_action(self, names_chronics_to_backend: Optional[Dict[Literal["load raise Grid2OpException(f"The action to set the grid to its original configuration " f"is ambiguous. Please check {self.init_state_handler.path}") from reason return act + + def regenerate_with_new_seed(self): + for handl in self._active_handlers: + handl.regenerate_with_new_seed() diff --git a/grid2op/MakeEnv/PathUtils.py b/grid2op/MakeEnv/PathUtils.py index 99db27b5..33611eef 100644 --- a/grid2op/MakeEnv/PathUtils.py +++ b/grid2op/MakeEnv/PathUtils.py @@ -51,7 +51,8 @@ def str_to_bool(string: str) -> bool: USE_CLASS_IN_FILE = str_to_bool(os.environ[KEY_CLASS_IN_FILE]) except ValueError as exc: raise RuntimeError(f"Impossible to read the behaviour from `{KEY_CLASS_IN_FILE}` environment variable") from exc - + + USE_CLASS_IN_FILE = False # deactivated until further notice def _create_path_folder(data_path): if not os.path.exists(data_path): diff --git a/grid2op/Runner/runner.py b/grid2op/Runner/runner.py index 85408241..ce646754 100644 --- a/grid2op/Runner/runner.py +++ b/grid2op/Runner/runner.py @@ -21,7 +21,7 @@ from grid2op.Reward import FlatReward, BaseReward from grid2op.Rules import AlwaysLegal from grid2op.Environment import Environment -from grid2op.Chronics import ChronicsHandler, GridStateFromFile, GridValue +from grid2op.Chronics import ChronicsHandler, GridStateFromFile, GridValue, MultifolderWithCache from grid2op.Backend import Backend, PandaPowerBackend from grid2op.Parameters import Parameters from grid2op.Agent import DoNothingAgent, BaseAgent @@ -431,7 +431,11 @@ def __init__( 'grid2op.GridValue. Please modify "gridStateclass" parameter.' ) self.gridStateclass = gridStateclass - + if issubclass(gridStateclass, MultifolderWithCache): + warnings.warn("We do not recommend to use the `MultifolderWithCache` during the " + "evaluation of your agents. It is possible but you might end up with " + "side effects (see issue 616 for example). It is safer to use the " + "`Multifolder` class as a drop-in replacement.") self.envClass._check_rules_correct(legalActClass) self.legalActClass = legalActClass