diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index c9286822..a8095a15 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -18,11 +18,11 @@ jobs: strategy: matrix: python: - - { - name: cp38, - abi: cp38, - version: '3.8', - } + # - { + # name: cp38, + # abi: cp38, + # version: '3.8', + # } - { name: cp39, abi: cp39, diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 83d15bcb..22937bce 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -104,6 +104,9 @@ Native multi agents support: -------------------- - [FIXED] license issues in the documentation - [IMPROVED] tests splitting to reduce the duration on circle ci +- [IMPROVED] handling of redispatching as a separate module now + (grid2op/Environment/dispatch) +- [IMPROVED] remove the use of "assert" block in the main codebase [1.12.4] - 2026-04-28 ---------------------- diff --git a/grid2op/Chronics/fromNPY.py b/grid2op/Chronics/fromNPY.py index 3c7f03a8..efdbc840 100644 --- a/grid2op/Chronics/fromNPY.py +++ b/grid2op/Chronics/fromNPY.py @@ -417,7 +417,7 @@ def check_validity( if self._prod_p.shape[0] != self._forecasts._prod_p.shape[0]: raise ChronicsError("self._prod_p.shape[0] != self._forecasts._prod_p.shape[0]") if self._prod_v is not None and self._forecasts._prod_v is not None: - if self._prod_v.shape[0] == self._forecasts._prod_v.shape[0]: + if self._prod_v.shape[0] != self._forecasts._prod_v.shape[0]: raise ChronicsError("self._prod_v.shape[0] != self._forecasts._prod_v.shape[0]") self._forecasts.check_validity(backend=backend) diff --git a/grid2op/Environment/baseEnv.py b/grid2op/Environment/baseEnv.py index 0716998d..a559df79 100644 --- a/grid2op/Environment/baseEnv.py +++ b/grid2op/Environment/baseEnv.py @@ -19,7 +19,6 @@ import warnings import numpy as np -from scipy.optimize import (minimize, LinearConstraint) from abc import ABC, abstractmethod from grid2op._glop_platform_info import _IS_WINDOWS @@ -59,6 +58,19 @@ from grid2op.Rules import AlwaysLegal, BaseRules from grid2op.typing_variables import STEP_INFO_TYPING, RESET_OPTIONS_TYPING from grid2op.VoltageControler import ControlVoltageFromFile +from grid2op.Environment.dispatch import ( + BaseRedispatchSolver, + CurtailmentModule, + CurtailmentResult, + DefaultRedispatchSolver, + DetachmentModule, + DetachmentResult, + FeasibilityGuard, + RedispatchConstraints, + RedispatchState, + StorageModule, + StorageResult, +) # TODO put in a separate class the redispatching function @@ -323,6 +335,7 @@ def foo(manager): ALARM_KEY = "fixed" ALERT_FILE_NAME = "alerts_info.json" ALERT_KEY = "by_line" + DETAILED_REDISP_ERR_MSG = DETAILED_REDISP_ERR_MSG CAN_SKIP_TS = False # each step is exactly one time step @@ -374,6 +387,7 @@ def __init__( _local_dir_cls=None, _read_from_local_dir=None, _raw_backend_class=None, + redispatch_solver: Optional[BaseRedispatchSolver] = None, ): #: flag to indicate not to erase the directory when the env has been used self._do_not_erase_local_dir_cls = False @@ -493,6 +507,7 @@ def __init__( self._hazard_duration: np.ndarray = None self._env_dc = self._parameters.ENV_DC + self._dispatch_state = RedispatchState() # redispatching data self._target_dispatch: np.ndarray = None @@ -658,6 +673,7 @@ def __init__( self._highres_sim_counter = HighResSimCounter() self._update_obs_after_reward = update_obs_after_reward + self._init_dispatch_modules(redispatch_solver) # alert self._last_alert = None @@ -696,7 +712,208 @@ def __init__( # 1.12.1 self._needs_active_bus = False - + + def _init_dispatch_modules( + self, + redispatch_solver: Optional[BaseRedispatchSolver], + ) -> None: + self._storage_module = StorageModule(self) + self._curtailment_module = CurtailmentModule(self) + self._detachment_module = DetachmentModule(self) + self._feasibility_guard = FeasibilityGuard(self) + if redispatch_solver is None: + solver = DefaultRedispatchSolver() + elif isinstance(redispatch_solver, type): + solver = redispatch_solver() + else: + solver = redispatch_solver + self._redispatch_solver = solver.bind(self) + + def _get_dispatch_attr(self, name: str): + return getattr(self._dispatch_state, name) + + def _set_dispatch_attr(self, name: str, value) -> None: + setattr(self._dispatch_state, name, value) + + def _set_dispatch_float_attr(self, name: str, value) -> None: + setattr(self._dispatch_state, name, dt_float(value)) + + @property + def _target_dispatch(self): + return self._get_dispatch_attr("target_dispatch") + + @_target_dispatch.setter + def _target_dispatch(self, value): + self._set_dispatch_attr("target_dispatch", value) + + @property + def _already_modified_gen(self): + return self._get_dispatch_attr("already_modified_gen") + + @_already_modified_gen.setter + def _already_modified_gen(self, value): + self._set_dispatch_attr("already_modified_gen", value) + + @property + def _actual_dispatch(self): + return self._get_dispatch_attr("actual_dispatch") + + @_actual_dispatch.setter + def _actual_dispatch(self, value): + self._set_dispatch_attr("actual_dispatch", value) + + @property + def _gen_uptime(self): + return self._get_dispatch_attr("gen_uptime") + + @_gen_uptime.setter + def _gen_uptime(self, value): + self._set_dispatch_attr("gen_uptime", value) + + @property + def _gen_downtime(self): + return self._get_dispatch_attr("gen_downtime") + + @_gen_downtime.setter + def _gen_downtime(self, value): + self._set_dispatch_attr("gen_downtime", value) + + @property + def _gen_activeprod_t(self): + return self._get_dispatch_attr("gen_activeprod_t") + + @_gen_activeprod_t.setter + def _gen_activeprod_t(self, value): + self._set_dispatch_attr("gen_activeprod_t", value) + + @property + def _gen_activeprod_t_redisp(self): + return self._get_dispatch_attr("gen_activeprod_t_redisp") + + @_gen_activeprod_t_redisp.setter + def _gen_activeprod_t_redisp(self, value): + self._set_dispatch_attr("gen_activeprod_t_redisp", value) + + @property + def _storage_current_charge(self): + return self._get_dispatch_attr("storage_current_charge") + + @_storage_current_charge.setter + def _storage_current_charge(self, value): + self._set_dispatch_attr("storage_current_charge", value) + + @property + def _storage_previous_charge(self): + return self._get_dispatch_attr("storage_previous_charge") + + @_storage_previous_charge.setter + def _storage_previous_charge(self, value): + self._set_dispatch_attr("storage_previous_charge", value) + + @property + def _action_storage(self): + return self._get_dispatch_attr("action_storage") + + @_action_storage.setter + def _action_storage(self, value): + self._set_dispatch_attr("action_storage", value) + + @property + def _amount_storage(self): + return self._get_dispatch_attr("amount_storage") + + @_amount_storage.setter + def _amount_storage(self, value): + self._set_dispatch_float_attr("amount_storage", value) + + @property + def _amount_storage_prev(self): + return self._get_dispatch_attr("amount_storage_prev") + + @_amount_storage_prev.setter + def _amount_storage_prev(self, value): + self._set_dispatch_float_attr("amount_storage_prev", value) + + @property + def _storage_power(self): + return self._get_dispatch_attr("storage_power") + + @_storage_power.setter + def _storage_power(self, value): + self._set_dispatch_attr("storage_power", value) + + @property + def _storage_power_prev(self): + return self._get_dispatch_attr("storage_power_prev") + + @_storage_power_prev.setter + def _storage_power_prev(self, value): + self._set_dispatch_attr("storage_power_prev", value) + + @property + def _limit_curtailment(self): + return self._get_dispatch_attr("limit_curtailment") + + @_limit_curtailment.setter + def _limit_curtailment(self, value): + self._set_dispatch_attr("limit_curtailment", value) + + @property + def _limit_curtailment_prev(self): + return self._get_dispatch_attr("limit_curtailment_prev") + + @_limit_curtailment_prev.setter + def _limit_curtailment_prev(self, value): + self._set_dispatch_attr("limit_curtailment_prev", value) + + @property + def _gen_before_curtailment(self): + return self._get_dispatch_attr("gen_before_curtailment") + + @_gen_before_curtailment.setter + def _gen_before_curtailment(self, value): + self._set_dispatch_attr("gen_before_curtailment", value) + + @property + def _sum_curtailment_mw(self): + return self._get_dispatch_attr("sum_curtailment_mw") + + @_sum_curtailment_mw.setter + def _sum_curtailment_mw(self, value): + self._set_dispatch_float_attr("sum_curtailment_mw", value) + + @property + def _sum_curtailment_mw_prev(self): + return self._get_dispatch_attr("sum_curtailment_mw_prev") + + @_sum_curtailment_mw_prev.setter + def _sum_curtailment_mw_prev(self, value): + self._set_dispatch_float_attr("sum_curtailment_mw_prev", value) + + @property + def _detached_elements_mw(self): + return self._get_dispatch_attr("detached_elements_mw") + + @_detached_elements_mw.setter + def _detached_elements_mw(self, value): + self._set_dispatch_float_attr("detached_elements_mw", value) + + @property + def _detached_elements_mw_prev(self): + return self._get_dispatch_attr("detached_elements_mw_prev") + + @_detached_elements_mw_prev.setter + def _detached_elements_mw_prev(self, value): + self._set_dispatch_float_attr("detached_elements_mw_prev", value) + + @property + def _limited_before(self): + return self._get_dispatch_attr("limited_before") + + @_limited_before.setter + def _limited_before(self, value): + self._set_dispatch_float_attr("limited_before", value) + @property def highres_sim_counter(self) -> int: return self._highres_sim_counter @@ -718,6 +935,7 @@ def _custom_deepcopy_for_copy(self, new_obj, dict_=None): RandomObject._custom_deepcopy_for_copy(self, new_obj) new_obj.name = self.name + new_obj._dispatch_state = RedispatchState() if dict_ is None: dict_ = {} new_obj._n_busbar = self._n_busbar @@ -1026,6 +1244,7 @@ def _custom_deepcopy_for_copy(self, new_obj, dict_=None): new_obj._called_from_reset = self._called_from_reset new_obj._needs_active_bus = self._needs_active_bus + new_obj._init_dispatch_modules(self._redispatch_solver.copy_for_env(new_obj)) def get_path_env(self): """ @@ -1421,14 +1640,7 @@ def _has_been_initialized(self): shape=(bk_type.n_line,), fill_value=0, dtype=dt_int ) - # create the vector to the proper shape - self._target_dispatch = np.zeros(bk_type.n_gen, dtype=dt_float) - self._already_modified_gen = np.zeros(bk_type.n_gen, dtype=dt_bool) - self._actual_dispatch = np.zeros(bk_type.n_gen, dtype=dt_float) - self._gen_uptime = np.zeros(bk_type.n_gen, dtype=dt_int) - self._gen_downtime = np.zeros(bk_type.n_gen, dtype=dt_int) - self._gen_activeprod_t = np.zeros(bk_type.n_gen, dtype=dt_float) - self._gen_activeprod_t_redisp = np.zeros(bk_type.n_gen, dtype=dt_float) + self._dispatch_state = RedispatchState.allocate(bk_type.n_gen, bk_type.n_storage) self._max_timestep_line_status_deactivated = ( self._parameters.NB_TIMESTEP_COOLDOWN_LINE ) @@ -1458,27 +1670,6 @@ def _has_been_initialized(self): self._reset_redispatching() - # storage - self._storage_current_charge = np.zeros(bk_type.n_storage, dtype=dt_float) - self._storage_previous_charge = np.zeros(bk_type.n_storage, dtype=dt_float) - self._action_storage = np.zeros(bk_type.n_storage, dtype=dt_float) - self._storage_power = np.zeros(bk_type.n_storage, dtype=dt_float) - self._storage_power_prev = np.zeros(bk_type.n_storage, dtype=dt_float) - self._amount_storage = 0.0 - self._amount_storage_prev = 0.0 - - # curtailment - self._limit_curtailment = np.ones( - bk_type.n_gen, dtype=dt_float - ) # in ratio of pmax - self._limit_curtailment_prev = np.ones( - bk_type.n_gen, dtype=dt_float - ) # in ratio of pmax - self._gen_before_curtailment = np.zeros(bk_type.n_gen, dtype=dt_float) # in MW - self._sum_curtailment_mw = dt_float(0.0) - self._sum_curtailment_mw_prev = dt_float(0.0) - self._detached_elements_mw = dt_float(0.0) - self._detached_elements_mw_prev = dt_float(0.0) self._reset_curtailment() # register this is properly initialized @@ -1686,27 +1877,10 @@ def _reset_alert(self): def _reset_storage(self): """reset storage capacity at the beginning of new environment if needed""" - if self.n_storage > 0: - tmp = self._parameters.INIT_STORAGE_CAPACITY * self.storage_Emax - if self._parameters.ACTIVATE_STORAGE_LOSS: - tmp += self.storage_loss * self.delta_time_seconds / 3600.0 - self._storage_previous_charge[ - : - ] = tmp # might not be needed, but it's not for the time it takes... - self._storage_current_charge[:] = tmp - self._storage_power[:] = 0.0 - self._storage_power_prev[:] = 0.0 - self._amount_storage = 0.0 - self._amount_storage_prev = 0.0 - # TODO storage: check in simulate too! + self._storage_module.reset(self._dispatch_state) def _reset_curtailment(self): - self._limit_curtailment[self.gen_renewable] = 1.0 - self._limit_curtailment_prev[self.gen_renewable] = 1.0 - self._gen_before_curtailment[:] = 0.0 - self._sum_curtailment_mw = dt_float(0.0) - self._sum_curtailment_mw_prev = dt_float(0.0) - self._limited_before = dt_float(0.0) + self._curtailment_module.reset(self._dispatch_state) def seed(self, seed=None, _seed_me=True): """ @@ -2058,21 +2232,11 @@ def set_thermal_limit(self, thermal_limit): self.observation_space.set_thermal_limit(self._thermal_limit_a) def _reset_redispatching(self): - # redispatching - self._target_dispatch[:] = 0.0 - self._already_modified_gen[:] = False - self._actual_dispatch[:] = 0.0 - self._gen_uptime[:] = 0 - self._gen_downtime[:] = 0 - self._gen_activeprod_t[:] = 0.0 - self._gen_activeprod_t_redisp[:] = 0.0 + self._redispatch_solver.reset(self._dispatch_state) def _feed_data_for_detachment(self, new_p_th): """feed the attribute for the detachment""" - - self._prev_gen_p[:] = new_p_th - self._aux_retrieve_modif_act(self._prev_load_p, self._env_modification, "load_p") - self._aux_retrieve_modif_act(self._prev_load_q, self._env_modification, "load_q") + self._detachment_module.feed_data(new_p_th, self._dispatch_state) def _aux_retrieve_modif_act(self, input_ : np.ndarray, @@ -2099,419 +2263,35 @@ def _get_new_prod_setpoint(self, action): return new_p def _get_already_modified_gen(self, action: BaseAction): - if not action._modif_redispatch: - # nothing changes if the action does - # not affect redispatching - return self._already_modified_gen - - redisp_act_orig = action._redispatch - is_redisped = np.abs(redisp_act_orig) > 1e-7 - self._target_dispatch[self._already_modified_gen] += redisp_act_orig[self._already_modified_gen] - first_modified = (~self._already_modified_gen) & is_redisped - self._target_dispatch[first_modified] = ( - self._actual_dispatch[first_modified] + redisp_act_orig[first_modified] - ) - self._already_modified_gen[is_redisped] = True - return self._already_modified_gen + return self._redispatch_solver._update_target_dispatch(action, self._dispatch_state) def _prepare_redisp(self, action: BaseAction, new_p, already_modified_gen): - cls = type(self) - # trying with an optimization method - except_ = None - info_ = [] - valid = True - - # get the redispatching action (if any) - if action._modif_redispatch: - redisp_act_orig = action._redispatch.copy() - else: - redisp_act_orig = None - - if ( - (redisp_act_orig is not None and (np.abs(redisp_act_orig) <= 1e-7).all()) - and (np.abs(self._target_dispatch) <= 1e-7).all() - and (np.abs(self._actual_dispatch) <= 1e-7).all() - ): - return valid, except_, info_ - - if redisp_act_orig is None: - redisp_act_orig = type(action)._build_attr("_redispatch") - - # check that everything is consistent with pmin, pmax: - if (self._target_dispatch > cls.gen_pmax - cls.gen_pmin).any(): - # action is invalid, the target redispatching would be above pmax for at least a generator - cond_invalid = self._target_dispatch > cls.gen_pmax - cls.gen_pmin - except_ = IllegalRedispatching( - "You cannot ask for a dispatch higher than pmax - pmin [it would be always " - "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((cond_invalid).nonzero()[0]) - ) - self._target_dispatch -= redisp_act_orig - return valid, except_, info_ - if (self._target_dispatch < cls.gen_pmin - cls.gen_pmax).any(): - # action is invalid, the target redispatching would be below pmin for at least a generator - cond_invalid = self._target_dispatch < cls.gen_pmin - cls.gen_pmax - except_ = IllegalRedispatching( - "You cannot ask for a dispatch lower than pmin - pmax [it would be always " - "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((cond_invalid).nonzero()[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[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_ = IllegalRedispatching( - "Impossible to dispatch a turned off generator" - ) - self._target_dispatch -= redisp_act_orig - return valid, except_, info_ - - if self._forbid_dispatch_off: - redisp_act_orig_cut = redisp_act_orig.copy() - 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": ( - redisp_act_orig_cut != redisp_act_orig - ).nonzero()[0] - } - ) - return valid, except_, info_ + return self._redispatch_solver._validate_dispatch( + action, new_p, already_modified_gen, self._dispatch_state + ) def _make_redisp(self, already_modified_gen, new_p): """this computes the redispaching vector, taking into account the storage units""" - except_ = None - valid = True - if not self._parameters.ENV_DOES_REDISPATCHING: - # env redispatching routine is asked not - # to work - self._actual_dispatch[:] = self._target_dispatch.copy() - return valid, except_ - - mismatch = self._actual_dispatch - self._target_dispatch - mismatch = np.abs(mismatch) - if ( - np.abs((self._actual_dispatch).sum()) >= self._tol_poly - or np.max(mismatch) >= self._tol_poly - or np.abs(self._amount_storage) >= self._tol_poly - or np.abs(self._sum_curtailment_mw) >= self._tol_poly - or np.abs(self._detached_elements_mw) >= self._tol_poly - ): - except_ = self._compute_dispatch_vect(already_modified_gen, new_p) - valid = except_ is None - return valid, except_ - - def _compute_dispatch_vect(self, already_modified_gen, new_p): - except_ = None - cls = type(self) - this_dt_float = float # to be compliant with scipy 1.16 (removes np.float32) - # handle the case where there are storage or redispatching - # action or curtailment action on the "init state" - # of the grid - if self.nb_time_step == 0: - self._gen_activeprod_t_redisp[:] = new_p - - # first i define the participating generators - # these are the generators that will be adjusted for redispatching - gen_participating = ( - (new_p > 0.0) - | (np.abs(self._actual_dispatch) >= 1e-7) - | (self._target_dispatch != self._actual_dispatch) - ) - gen_participating[~cls.gen_redispatchable] = False - if cls.detachment_is_allowed: - gen_participating[self._backend_action.get_gen_detached()] = False - incr_in_chronics = new_p - ( - self._gen_activeprod_t_redisp - self._actual_dispatch + constraints = RedispatchConstraints.from_results( + None, None, None, new_p, self._dispatch_state, self ) + except_ = self._redispatch_solver.solve(constraints, self._dispatch_state) + return except_ is None, except_ - # check if the constraints are violated - ## total available "juice" to go down (incl ramp and pmin / pmax) - p_min_down = ( - cls.gen_pmin[gen_participating] - - self._gen_activeprod_t_redisp[gen_participating] - ) - avail_down = np.maximum(p_min_down, -cls.gen_max_ramp_down[gen_participating]) - ## total available "juice" to go up (incl. ramp and pmin / pmax) - p_max_up = ( - cls.gen_pmax[gen_participating] - - self._gen_activeprod_t_redisp[gen_participating] - ) - avail_up = np.minimum(p_max_up, cls.gen_max_ramp_up[gen_participating]) - except_ = self._detect_infeasible_dispatch( - incr_in_chronics[gen_participating], avail_down, avail_up - ) - if except_ is not None: - # try to force the turn on of turned off generators (if parameters allow it) - if ( - self._parameters.IGNORE_MIN_UP_DOWN_TIME - and self._parameters.ALLOW_DISPATCH_GEN_SWITCH_OFF - ): - gen_participating_tmp = self.gen_redispatchable - if cls.detachment_is_allowed: - gen_participating_tmp[self._backend_action.get_gen_detached()] = False - p_min_down_tmp = ( - cls.gen_pmin[gen_participating_tmp] - - self._gen_activeprod_t_redisp[gen_participating_tmp] - ) - avail_down_tmp = np.maximum( - p_min_down_tmp, -cls.gen_max_ramp_down[gen_participating_tmp] - ) - p_max_up_tmp = ( - cls.gen_pmax[gen_participating_tmp] - - self._gen_activeprod_t_redisp[gen_participating_tmp] - ) - avail_up_tmp = np.minimum( - p_max_up_tmp, cls.gen_max_ramp_up[gen_participating_tmp] - ) - except_tmp = self._detect_infeasible_dispatch( - incr_in_chronics[gen_participating_tmp], - avail_down_tmp, - avail_up_tmp, - ) - if except_tmp is None: - # I can "save" the situation by turning on all generators, I do it - # TODO logger here - gen_participating = gen_participating_tmp - except_ = None - else: - return except_tmp - else: - return except_ - - # define the objective value - target_vals = ( - self._target_dispatch[gen_participating] - - self._actual_dispatch[gen_participating] - ) - - already_modified_gen_me = already_modified_gen[gen_participating] - target_vals_me = target_vals[already_modified_gen_me] - nb_dispatchable = gen_participating.sum() - tmp_zeros = np.zeros((1, nb_dispatchable), dtype=this_dt_float) - coeffs = 1.0 / ( - self.gen_max_ramp_up + self.gen_max_ramp_down + self._epsilon_poly - ) - weights = np.ones(nb_dispatchable) * coeffs[gen_participating] - weights /= weights.sum() - - if target_vals_me.shape[0] == 0: - # no dispatch means all dispatchable, otherwise i will never get to 0 - already_modified_gen_me[:] = True - target_vals_me = target_vals[already_modified_gen_me] - - # for numeric stability - # to scale the input also: - # see https://stackoverflow.com/questions/11155721/positive-directional-derivative-for-linesearch - scale_x = max(np.max(np.abs(self._actual_dispatch)), 1.0) - scale_x = this_dt_float(scale_x) - target_vals_me_optim = 1.0 * (target_vals_me / scale_x) - target_vals_me_optim = target_vals_me_optim.astype(this_dt_float) - - # see https://stackoverflow.com/questions/11155721/positive-directional-derivative-for-linesearch - # where they advised to scale the function - scale_objective = max(0.5 * np.abs(target_vals_me_optim).sum() ** 2, 1.0) - scale_objective = np.round(scale_objective, decimals=4) - scale_objective = this_dt_float(scale_objective) - - # add the "sum to 0" - mat_sum_0_no_turn_on = np.ones((1, nb_dispatchable), dtype=this_dt_float) - # this is where the storage is taken into account - # storages are "load convention" this means that i need to sum the amount of production to sum of storage - # hence the "+ self._amount_storage" below - # self._sum_curtailment_mw is "generator convention" hence the "-" there - const_sum_0_no_turn_on = ( - np.zeros(1, dtype=this_dt_float) - + self._amount_storage - - self._sum_curtailment_mw - + self._detached_elements_mw - ) - - # gen increase in the chronics - new_p_th = new_p[gen_participating] + self._actual_dispatch[gen_participating] - - # minimum value available for disp - ## first limit delta because of pmin - p_min_const = self.gen_pmin[gen_participating] - new_p_th - ## second limit delta because of ramps - ramp_down_const = ( - -self.gen_max_ramp_down[gen_participating] - - incr_in_chronics[gen_participating] - ) - ## take max of the 2 - min_disp = np.maximum(p_min_const, ramp_down_const) - min_disp = min_disp.astype(this_dt_float) - - # maximum value available for disp - ## first limit delta because of pmin - p_max_const = self.gen_pmax[gen_participating] - new_p_th - ## second limit delta because of ramps - ramp_up_const = ( - self.gen_max_ramp_up[gen_participating] - - incr_in_chronics[gen_participating] - ) - ## take min of the 2 - max_disp = np.minimum(p_max_const, ramp_up_const) - max_disp = max_disp.astype(this_dt_float) - - # add everything into a linear constraint object - # equality - added = 0.5 * self._epsilon_poly - equality_const = LinearConstraint( - mat_sum_0_no_turn_on, # do the sum - (const_sum_0_no_turn_on) / scale_x, # lower bound - (const_sum_0_no_turn_on) / scale_x, # upper bound - ) - mat_pmin_max_ramps = np.eye(nb_dispatchable) - ineq_const = LinearConstraint( - mat_pmin_max_ramps, - (min_disp - added) / scale_x, - (max_disp + added) / scale_x, + def _compute_dispatch_vect(self, already_modified_gen, new_p): + constraints = RedispatchConstraints.from_results( + None, None, None, new_p, self._dispatch_state, self ) - - # choose a good initial point (close to the solution) - # 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(), dtype=this_dt_float) - 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] - - self._actual_dispatch[gen_participating][gen_for_x0] - ) / scale_x - # at this point x0 is made of the difference between the target and the - # actual dispatch for all generators that have a - # target dispatch non 0. - - # in this "if" block I set the other component of x0 to - # their "right" value - can_adjust = (np.abs(x0) <= 1e-7) - if can_adjust.any(): - init_sum = x0.sum() - denom_adjust = (1.0 / weights[can_adjust]).sum() - if denom_adjust <= 1e-2: - # i don't want to divide by something too cloose to 0. - denom_adjust = 1.0 - x0[can_adjust] = -init_sum / (weights[can_adjust] * denom_adjust) - else: - # to "force" the exact reset to 0.0 for all components - x0 -= self._actual_dispatch[gen_participating] / scale_x - - def target(actual_dispatchable): - # define my real objective - quad_ = ( - actual_dispatchable[already_modified_gen_me] - target_vals_me_optim - ) ** 2 - coeffs_quads = weights[already_modified_gen_me] * quad_ - coeffs_quads_const = coeffs_quads.sum() - coeffs_quads_const /= scale_objective # scaling the function - return coeffs_quads_const - - def jac(actual_dispatchable): - res_jac = 1.0 * tmp_zeros - res_jac[0, already_modified_gen_me] = ( - 2.0 - * weights[already_modified_gen_me] - * (actual_dispatchable[already_modified_gen_me] - target_vals_me_optim) - ) - res_jac /= scale_objective # scaling the function - return res_jac.reshape(-1) - - # objective function - def f(init): - this_res = minimize( - target, - init, - method="SLSQP", - constraints=[equality_const, ineq_const], - options={ - "eps": max(this_dt_float(self._epsilon_poly / scale_x), 1e-6), - "ftol": max(this_dt_float(self._epsilon_poly / scale_x), 1e-6), - "disp": False, - }, - jac=jac - # hess=hess # not used for SLSQP - ) - return this_res - res = f(x0) - if res.success: - self._actual_dispatch[gen_participating] += res.x * scale_x - else: - # check if constraints are "approximately" met - mat_const = np.concatenate((mat_sum_0_no_turn_on, mat_pmin_max_ramps)) - downs = np.concatenate( - (const_sum_0_no_turn_on / scale_x, (min_disp - added) / scale_x) - ) - ups = np.concatenate( - (const_sum_0_no_turn_on / scale_x, (max_disp + added) / scale_x) - ) - vals = np.matmul(mat_const, res.x) - ok_down = np.all( - vals - downs >= -self._tol_poly - ) # i don't violate "down" constraints - ok_up = np.all(vals - ups <= self._tol_poly) - if ok_up and ok_down: - # it's ok i can tolerate "small" perturbations - self._actual_dispatch[gen_participating] += res.x * scale_x - else: - # TODO try with another method here, maybe - error_dispatch = ( - "Redispatching automaton terminated with error (no more information available " - 'at this point):\n"{}"'.format(res.message) - ) - except_ = ImpossibleRedispatching(error_dispatch) - return except_ + return self._redispatch_solver._solve(constraints, self._dispatch_state) def _detect_infeasible_dispatch(self, incr_in_chronics, avail_down, avail_up): """This function is an attempt to give more detailed log by detecting infeasible dispatch""" - except_ = None - sum_move = ( - incr_in_chronics.sum() + self._amount_storage - self._sum_curtailment_mw + self._detached_elements_mw + constraints = RedispatchConstraints.from_results( + None, None, None, self._gen_activeprod_t, self._dispatch_state, self + ) + return self._redispatch_solver._detect_infeasible_dispatch( + constraints, incr_in_chronics, avail_down, avail_up, self._dispatch_state ) - avail_down_sum = avail_down.sum() - avail_up_sum = avail_up.sum() - gen_setpoint = self._gen_activeprod_t_redisp[self.gen_redispatchable] - if sum_move > avail_up_sum: - # infeasible because too much is asked - msg = DETAILED_REDISP_ERR_MSG.format( - sum_move=sum_move, - avail_up_sum=avail_up_sum, - gen_setpoint=np.round(gen_setpoint, decimals=2), - ramp_up=self.gen_max_ramp_up[self.gen_redispatchable], - gen_pmax=self.gen_pmax[self.gen_redispatchable], - avail_up=np.round(avail_up, decimals=2), - increase="increase", - decrease="decrease", - maximum="maximum", - pmax="pmax", - max_ramp_up="max_ramp_up", - ) - except_ = ImpossibleRedispatching(msg) - elif sum_move < avail_down_sum: - # infeasible because not enough is asked - msg = DETAILED_REDISP_ERR_MSG.format( - sum_move=sum_move, - avail_up_sum=avail_down_sum, - gen_setpoint=np.round(gen_setpoint, decimals=2), - ramp_up=self.gen_max_ramp_down[self.gen_redispatchable], - gen_pmax=self.gen_pmin[self.gen_redispatchable], - avail_up=np.round(avail_up, decimals=2), - increase="decrease", - decrease="increase", - maximum="minimum", - pmax="pmin", - max_ramp_up="max_ramp_down", - ) - except_ = ImpossibleRedispatching(msg) - return except_ def _update_actions(self): """ @@ -2629,68 +2409,9 @@ def _handle_updown_times(self, gen_up_before, redisp_act): Handles the up and down tims for the generators. """ - # get the generators that are not connected after the action - except_ = None - cls = type(self) - # computes which generator will be turned on after the action - gen_up_after = self._gen_activeprod_t.copy() - if "prod_p" in self._env_modification._dict_inj: - tmp = self._env_modification._dict_inj["prod_p"] - indx_ok = np.isfinite(tmp) - gen_up_after[indx_ok] = self._env_modification._dict_inj["prod_p"][indx_ok] - gen_up_after += redisp_act - gen_up_after = np.abs(gen_up_after) > 1e-7 - - # update min down time, min up time etc. - gen_disconnected_this = gen_up_before & (~gen_up_after) - gen_connected_this_timestep = (~gen_up_before) & (gen_up_after) & ~self._gens_detached - gen_still_connected = (gen_up_before & gen_up_after) - gen_still_disconnected = ((~gen_up_before) & (~gen_up_after)) | self._gens_detached - if (not self._ignore_min_up_down_times and - ( - self._gen_downtime[gen_connected_this_timestep] - < cls.gen_min_downtime[gen_connected_this_timestep] - ).any() - ): - # i reconnected a generator before the minimum time allowed - id_gen = ( - self._gen_downtime[gen_connected_this_timestep] - < cls.gen_min_downtime[gen_connected_this_timestep] - ) - id_gen = (id_gen).nonzero()[0] - id_gen = (gen_connected_this_timestep[id_gen]).nonzero()[0] - except_ = GeneratorTurnedOnTooSoon( - "Some generator has been connected too early ({})".format(id_gen) - ) - return except_ - else: - self._gen_downtime[gen_connected_this_timestep] = -1 - self._gen_uptime[gen_connected_this_timestep] = 0 - - if (not self._ignore_min_up_down_times and - ( - self._gen_uptime[gen_disconnected_this] - < cls.gen_min_uptime[gen_disconnected_this] - ).any() - ): - # i disconnected a generator before the minimum time allowed - id_gen = ( - self._gen_uptime[gen_disconnected_this] - < cls.gen_min_uptime[gen_disconnected_this] - ) - id_gen = (id_gen).nonzero()[0] - id_gen = (gen_disconnected_this[id_gen]).nonzero()[0] - except_ = GeneratorTurnedOffTooSoon( - "Some generator has been disconnected too early ({})".format(id_gen) - ) - return except_ - else: - self._gen_downtime[gen_disconnected_this] = 0 - self._gen_uptime[gen_disconnected_this] = -1 - - self._gen_uptime[gen_still_connected] += 1 - self._gen_downtime[gen_still_disconnected] += 1 - return except_ + return self._redispatch_solver._check_updown_times( + gen_up_before, redisp_act, self._dispatch_state + ) def get_obs(self, _update_state=True, _do_copy=True): """ @@ -2780,15 +2501,7 @@ def _withdraw_storage_losses(self): NB this is a loss, this is not seen grid side, so `storage_discharging_efficiency` has no impact on this """ - # NB this should be done AFTER the computation of self._amount_storage, because this energy is dissipated - # in the storage units, thus NOT seen as power from the grid. - if self._parameters.ACTIVATE_STORAGE_LOSS: - tmp_ = self.storage_loss * self.delta_time_seconds / 3600.0 - self._storage_current_charge -= tmp_ - # charge cannot be negative, but it can be below Emin if there are some uncompensated losses - self._storage_current_charge[:] = np.maximum( - self._storage_current_charge, 0.0 - ) + self._storage_module.withdraw_losses(self._dispatch_state) def _aux_remove_power_too_high(self, delta_, indx_too_high): """ @@ -2797,16 +2510,7 @@ def _aux_remove_power_too_high(self, delta_, indx_too_high): handles self._storage_power in case we need to cut the storage action because the power would be too high """ - coeff_p_to_E = ( - self.delta_time_seconds / 3600.0 - ) # TODO optim this is const for all time steps - tmp_ = 1.0 / coeff_p_to_E * delta_ - if self._parameters.ACTIVATE_STORAGE_LOSS: - # from the storage i need to reduce of tmp_ MW (to compensate the delta_ MWh) - # but when it's "transfer" to the grid i don't have the same amount (due to inefficiencies) - # it's a "/" because i need more energy from the grid than what the actual charge will be - tmp_ /= self.storage_charging_efficiency[indx_too_high] - self._storage_power[indx_too_high] -= tmp_ + self._storage_module._clamp_too_high(delta_, indx_too_high, self._dispatch_state) def _aux_remove_power_too_low(self, delta_, indx_too_low): """ @@ -2815,94 +2519,10 @@ def _aux_remove_power_too_low(self, delta_, indx_too_low): handles self._storage_power in case we need to cut the storage action because the power would be too low """ - coeff_p_to_E = ( - self.delta_time_seconds / 3600.0 - ) # TODO optim this is const for all time steps - tmp_ = 1.0 / coeff_p_to_E * delta_ - if self._parameters.ACTIVATE_STORAGE_LOSS: - # from the storage i need to increase of tmp_ MW (to compensate the delta_ MWh) - # but when it's "transfer" to the grid i don't have the same amount (due to inefficiencies) - # it's a "*" because i have less power on the grid than what is removed from the battery - tmp_ *= self.storage_discharging_efficiency[indx_too_low] - self._storage_power[indx_too_low] -= tmp_ + self._storage_module._clamp_too_low(delta_, indx_too_low, self._dispatch_state) def _compute_storage(self, action_storage_power): - self._storage_previous_charge[:] = self._storage_current_charge - 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 - coeff_p_to_E = ( - self.delta_time_seconds / 3600.0 - ) # TODO optim this is const for all time steps - if storage_act.any(): - modif = True - this_act_stor = action_storage_power[storage_act] - eff_ = np.ones(storage_act.sum()) - if self._parameters.ACTIVATE_STORAGE_LOSS: - fill_storage = ( - this_act_stor > 0.0 - ) # index of storages that sees their charge increasing - unfill_storage = ( - this_act_stor < 0.0 - ) # index of storages that sees their charge decreasing - eff_[fill_storage] *= self.storage_charging_efficiency[storage_act][ - fill_storage - ] - eff_[unfill_storage] /= self.storage_discharging_efficiency[ - storage_act - ][unfill_storage] - self._storage_current_charge[storage_act] += ( - this_act_stor * coeff_p_to_E * eff_ - ) - self._action_storage[storage_act] += action_storage_power[storage_act] - self._storage_power[storage_act] = this_act_stor - - if modif: - # indx when there is too much energy on the battery - indx_too_high = self._storage_current_charge > self.storage_Emax - if indx_too_high.any(): - delta_ = ( - self._storage_current_charge[indx_too_high] - - self.storage_Emax[indx_too_high] - ) - self._aux_remove_power_too_high(delta_, indx_too_high) - self._storage_current_charge[indx_too_high] = self.storage_Emax[ - indx_too_high - ] - - # indx when there is not enough energy on the battery - indx_too_low = self._storage_current_charge < self.storage_Emin - if indx_too_low.any(): - delta_ = ( - self._storage_current_charge[indx_too_low] - - self.storage_Emin[indx_too_low] - ) - self._aux_remove_power_too_low(delta_, indx_too_low) - self._storage_current_charge[indx_too_low] = self.storage_Emin[ - indx_too_low - ] - - self._storage_current_charge[:] = np.maximum( - self._storage_current_charge, self.storage_Emin - ) - # storage is "load convention", dispatch is "generator convention" - # i need the generator to have the same sign as the action on the batteries - self._amount_storage = self._storage_power.sum() - else: - # battery effect should be removed, so i multiply it by -1. - self._amount_storage = 0.0 - - tmp = self._amount_storage - self._amount_storage -= self._amount_storage_prev - self._amount_storage_prev = tmp - - # dissipated energy, it's not seen on the grid, just lost in the storage unit. - # this is why it should not be taken into account in self._amount_storage - # and NOT absorbed by the generators either - # NB loss in the storage unit can make it got below Emin in energy, but never below 0. - self._withdraw_storage_losses() - # end storage + return self._storage_module.compute(action_storage_power, self._dispatch_state) def _compute_max_ramp_this_step(self, new_p): """ @@ -2911,222 +2531,48 @@ def _compute_max_ramp_this_step(self, new_p): new_p: array of the (temporary) new production in the chronics that should happen """ - # TODO - # maximum value it can take - th_max = np.minimum( - self._gen_activeprod_t_redisp[self.gen_redispatchable] - + self.gen_max_ramp_up[self.gen_redispatchable], - self.gen_pmax[self.gen_redispatchable], - ) - # minimum value it can take - th_min = np.maximum( - self._gen_activeprod_t_redisp[self.gen_redispatchable] - - self.gen_max_ramp_down[self.gen_redispatchable], - self.gen_pmin[self.gen_redispatchable], - ) - - max_total_up = (th_max - new_p[self.gen_redispatchable]).sum() - max_total_down = ( - th_min - new_p[self.gen_redispatchable] - ).sum() # TODO is that it ? - return max_total_down, max_total_up + return self._feasibility_guard._compute_ramp_budget(new_p, self._dispatch_state) def _aux_update_curtail_env_act(self, new_p): - if "prod_p" in self._env_modification._dict_inj: - self._env_modification._dict_inj["prod_p"][:] = new_p - else: - self._env_modification._dict_inj["prod_p"] = 1.0 * new_p - self._env_modification._modif_inj = True + self._curtailment_module._update_env_action(new_p) def _aux_update_curtailment_act(self, action): - curtailment_act = 1.0 * action._curtail - ind_curtailed_in_act = (curtailment_act != -1.0) & self.gen_renewable - self._limit_curtailment_prev[:] = self._limit_curtailment - self._limit_curtailment[ind_curtailed_in_act] = curtailment_act[ - ind_curtailed_in_act - ] + self._curtailment_module._update_limits(action, self._dispatch_state) def _aux_compute_new_p_curtailment(self, new_p, curtailment_vect): """modifies the new_p argument !!!!""" - gen_curtailed = ( - 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]) - return gen_curtailed + return self._curtailment_module._apply_limits(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 (np.abs(self._limit_curtailment - 1.) >= 1e-7).any() - ): - self._aux_update_curtailment_act(action) - - gen_curtailed = self._aux_compute_new_p_curtailment( - new_p, self._limit_curtailment - ) - - tmp_sum_curtailment_mw = dt_float( - new_p[gen_curtailed].sum() - - self._gen_before_curtailment[gen_curtailed].sum() - ) - - self._sum_curtailment_mw = ( - tmp_sum_curtailment_mw - self._sum_curtailment_mw_prev - ) - self._sum_curtailment_mw_prev = tmp_sum_curtailment_mw - self._aux_update_curtail_env_act(new_p) - else: - self._sum_curtailment_mw = -self._sum_curtailment_mw_prev - self._sum_curtailment_mw_prev = dt_float(0.0) - gen_curtailed = np.abs(self._limit_curtailment - 1.) >= 1e-7 - - return gen_curtailed + return self._curtailment_module.compute(action, new_p, self._dispatch_state) def _aux_readjust_curtailment_after_limiting( self, total_curtailment, new_p_th, new_p ): - self._sum_curtailment_mw += total_curtailment - self._sum_curtailment_mw_prev += total_curtailment - if total_curtailment > self._tol_poly: - # in this case, the curtailment is too strong, I need to make it less strong - curtailed = new_p_th - new_p - else: - # in this case, the curtailment is too low, this can happen, for example when there is a - # "strong" curtailment but afterwards you ask to set everything to 1. (so no curtailment) - # I cannot reuse the previous case (too_much > self._tol_poly) because the - # curtailment is already computed there... - new_p_with_previous_curtailment = 1.0 * new_p_th - self._aux_compute_new_p_curtailment( - new_p_with_previous_curtailment, self._limit_curtailment_prev - ) - curtailed = new_p_th - new_p_with_previous_curtailment - - curt_sum = curtailed.sum() - if abs(curt_sum) > self._tol_poly: - curtailed[~self.gen_renewable] = 0.0 - curtailed *= total_curtailment / curt_sum - new_p[self.gen_renewable] += curtailed[self.gen_renewable] + self._feasibility_guard._readjust_curtailment( + total_curtailment, new_p_th, new_p, self._dispatch_state + ) def _aux_readjust_storage_after_limiting(self, total_storage): - new_act_storage = 1.0 * self._storage_power - sum_this_step = new_act_storage.sum() - if abs(total_storage) < abs(sum_this_step): - # i can modify the current action - modif_storage = new_act_storage * total_storage / sum_this_step - else: - # i need to retrieve what I did in a previous action - # because the current action is not enough (the previous actions - # cause a problem right now) - new_act_storage = 1.0 * self._storage_power_prev - sum_this_step = new_act_storage.sum() - if abs(sum_this_step) > 1e-1: - modif_storage = new_act_storage * total_storage / sum_this_step - else: - # TODO: this is not cover by any test :-( - # it happens when you do an action too strong, then a do nothing, - # then you decrease the limit to rapidly - # (game over would jappen after at least one do nothing) - - # In this case I reset it completely or do I ? I don't really - # know what to do ! - modif_storage = new_act_storage # or self._storage_power ??? - - # handle self._storage_power and self._storage_current_charge - coeff_p_to_E = ( - self.delta_time_seconds / 3600.0 - ) # TODO optim this is const for all time steps - self._storage_power -= modif_storage - - # now compute the state of charge of the storage units (with efficiencies) - is_discharging = self._storage_power < 0.0 - is_charging = self._storage_power > 0.0 - modif_storage[is_discharging] /= type(self).storage_discharging_efficiency[ - is_discharging - ] - modif_storage[is_charging] *= type(self).storage_charging_efficiency[ - is_charging - ] - - self._storage_current_charge -= coeff_p_to_E * modif_storage - # inform the grid that the storage is reduced - self._amount_storage -= total_storage - self._amount_storage_prev -= total_storage + self._feasibility_guard._readjust_storage(total_storage, self._dispatch_state) def _aux_limit_curtail_storage_if_needed(self, new_p, new_p_th, gen_curtailed): - gen_redisp = self.gen_redispatchable - - normal_increase = new_p - ( - self._gen_activeprod_t_redisp - self._actual_dispatch + storage_result = StorageResult( + amount_storage_mw=self._amount_storage, + storage_power=self._storage_power, ) - normal_increase = normal_increase[gen_redisp] - p_min_down = ( - self.gen_pmin[gen_redisp] - self._gen_activeprod_t_redisp[gen_redisp] + curtail_result = CurtailmentResult( + sum_curtailment_mw=self._sum_curtailment_mw, + gen_curtailed=gen_curtailed, ) - avail_down = np.maximum(p_min_down, -self.gen_max_ramp_down[gen_redisp]) - p_max_up = self.gen_pmax[gen_redisp] - self._gen_activeprod_t_redisp[gen_redisp] - avail_up = np.minimum(p_max_up, self.gen_max_ramp_up[gen_redisp]) - - sum_move = ( - normal_increase.sum() + self._amount_storage - self._sum_curtailment_mw + return self._feasibility_guard.check_and_clamp( + storage_result, + curtail_result, + new_p, + new_p_th, + self._dispatch_state, ) - total_storage_curtail = self._amount_storage - self._sum_curtailment_mw - update_env_act = False - - if abs(total_storage_curtail) >= self._tol_poly: - # if there is an impact on the curtailment / storage (otherwise I cannot fix anything) - too_much = 0.0 - if sum_move > avail_up.sum(): - # I need to limit curtailment (not enough ramps up available) - too_much = dt_float(sum_move - avail_up.sum() + self._tol_poly) - self._limited_before = too_much - elif sum_move < avail_down.sum(): - # I need to limit storage unit (not enough ramps down available) - too_much = dt_float(sum_move - avail_down.sum() - self._tol_poly) - self._limited_before = too_much - elif np.abs(self._limited_before) >= self._tol_poly: - # adjust the "mess" I did before by not curtailing enough - # max_action = self.gen_pmax[gen_curtailed] * self._limit_curtailment[gen_curtailed] - update_env_act = True - too_much = min(avail_up.sum() - self._tol_poly, self._limited_before) - self._limited_before -= too_much - too_much = self._limited_before - - if abs(too_much) > self._tol_poly: - total_curtailment = ( - -self._sum_curtailment_mw / total_storage_curtail * too_much - ) - total_storage = ( - self._amount_storage / total_storage_curtail * too_much - ) # TODO !!! - update_env_act = True - # TODO "log" the total_curtailment and total_storage somewhere (in the info part of the step function) - - if np.sign(total_curtailment) != np.sign(total_storage): - # curtailment goes up, storage down, i only "limit" the one that - # has the same sign as too much - total_curtailment = ( - too_much - if np.sign(total_curtailment) == np.sign(too_much) - else 0.0 - ) - total_storage = ( - too_much if np.sign(total_storage) == np.sign(too_much) else 0.0 - ) - # NB i can directly assign all the "curtailment" to the maximum because in this case, too_much will - # necessarily be > than total_curtail (or total_storage) because the other - # one is of opposite sign - - # fix curtailment - self._aux_readjust_curtailment_after_limiting( - total_curtailment, new_p_th, new_p - ) - - # fix storage - self._aux_readjust_storage_after_limiting(total_storage) - - if update_env_act: - self._aux_update_curtail_env_act(new_p) def _aux_handle_act_inj(self, action: BaseAction): for inj_key in ["load_p", "prod_p", "load_q"]: @@ -3173,7 +2619,9 @@ def _aux_apply_redisp(self, action: BaseAction, new_p: np.ndarray, new_p_th: np.ndarray, - gen_curtailed: np.ndarray, + storage_result: StorageResult, + curtail_result: CurtailmentResult, + detach_result: DetachmentResult, except_: List[Exception], powerline_status): cls = type(self) @@ -3216,13 +2664,27 @@ def _aux_apply_redisp(self, self.redispatching_unit_commitment_availble and self._parameters.LIMIT_INFEASIBLE_CURTAILMENT_STORAGE_ACTION ): - # limit the curtailment / storage in case of infeasible redispatching - self._aux_limit_curtail_storage_if_needed(new_p, new_p_th, gen_curtailed) + self._feasibility_guard.check_and_clamp( + storage_result, + curtail_result, + new_p, + new_p_th, + self._dispatch_state, + ) self._storage_power_prev[:] = self._storage_power # case where the action modifies load (TODO maybe make a different env for that...) self._aux_handle_act_inj(action) - valid_disp, except_tmp = self._make_redisp(already_modified_gen, new_p) + constraints = RedispatchConstraints.from_results( + storage_result, + curtail_result, + detach_result, + new_p, + self._dispatch_state, + self, + ) + except_tmp = self._redispatch_solver.solve(constraints, self._dispatch_state) + valid_disp = except_tmp is None if not valid_disp or except_tmp is not None: # game over case (divergence of the scipy routine to compute redispatching) @@ -3531,27 +2993,9 @@ def _aux_run_pf_after_state_properly_set( return detailed_info, has_error def _aux_apply_detachment(self, new_p, new_p_th): - gen_detached_user = self._backend_action.get_gen_detached() - load_detached_user = self._backend_action.get_load_detached() - - # handle gen - mw_gen_lost_this = new_p[gen_detached_user].sum() - - # handle loads - mw_load_lost_this = self._prev_load_p[load_detached_user].sum() - - # put everything together - total_power_lost = -mw_gen_lost_this + mw_load_lost_this - self._detached_elements_mw = (-total_power_lost + - self._actual_dispatch[gen_detached_user].sum() - - self._detached_elements_mw_prev) - self._detached_elements_mw_prev = -total_power_lost - - # and now modifies the vectors - new_p[gen_detached_user] = 0. - new_p_th[gen_detached_user] = 0. - self._actual_dispatch[gen_detached_user] = 0. - return new_p, new_p_th + return self._detachment_module.compute( + new_p, new_p_th, self._dispatch_state, self._backend_action + ) def _aux_step_reset_action(self): action = self._action_space({}) @@ -3784,19 +3228,22 @@ def step(self, action: BaseAction) -> Tuple[BaseObservation, self._feed_data_for_detachment(new_p_th) # should be called before _axu_apply_detachment # storage unit + storage_result = StorageResult() 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) + storage_result = self._compute_storage(action_storage_power) # curtailment (does not attempt to "limit" the curtailment to make sure # it is feasible) self._gen_before_curtailment[cls.gen_renewable] = new_p[cls.gen_renewable] - gen_curtailed = self._aux_handle_curtailment_without_limit(action, new_p) + curtail_result = self._aux_handle_curtailment_without_limit(action, new_p) # TODO detachment self._aux_update_backend_action(action, action_storage_power, init_disp) - new_p, new_p_th = self._aux_apply_detachment(new_p, new_p_th) + detach_result = self._aux_apply_detachment(new_p, new_p_th) + new_p = detach_result.new_p + new_p_th = detach_result.new_p_th beg__redisp = time.perf_counter() if (cls.redispatching_unit_commitment_availble or cls.n_storage > 0): @@ -3804,7 +3251,14 @@ def step(self, action: BaseAction) -> Tuple[BaseObservation, # and it is also in this function that the limiting of the curtailment / storage actions # is perform to make the state "feasible" res_disp = self._aux_apply_redisp( - action, new_p, new_p_th, gen_curtailed, except_, powerline_status + action, + new_p, + new_p_th, + storage_result, + curtail_result, + detach_result, + except_, + powerline_status, ) action, failed_redisp, is_illegal_reco, is_done = res_disp else: @@ -4210,9 +3664,18 @@ def close(self): "_is_alert_used_in_reward", "_kwargs_attention_budget", "_limited_before", + "_storage_module", + "_curtailment_module", + "_detachment_module", + "_feasibility_guard", + "_redispatch_solver", + "_dispatch_state", ]: if hasattr(self, attr_nm): - delattr(self, attr_nm) + try: + delattr(self, attr_nm) + except AttributeError: + pass setattr(self, attr_nm, None) if self._do_not_erase_local_dir_cls: diff --git a/grid2op/Environment/dispatch/__init__.py b/grid2op/Environment/dispatch/__init__.py new file mode 100644 index 00000000..479828d8 --- /dev/null +++ b/grid2op/Environment/dispatch/__init__.py @@ -0,0 +1,37 @@ +# Copyright (c) 2026, 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 .baseRedispatchSolver import BaseRedispatchSolver +from .curtailmentModule import CurtailmentModule +from .defaultRedispatchSolver import DefaultRedispatchSolver +from .detachmentModule import DetachmentModule +from .dispatchTypes import ( + CurtailmentResult, + DetachmentResult, + GuardInfo, + RedispatchConstraints, + RedispatchState, + StorageResult, +) +from .feasibilityGuard import FeasibilityGuard +from .storageModule import StorageModule + +__all__ = [ + "BaseRedispatchSolver", + "CurtailmentModule", + "CurtailmentResult", + "DefaultRedispatchSolver", + "DetachmentModule", + "DetachmentResult", + "FeasibilityGuard", + "GuardInfo", + "RedispatchConstraints", + "RedispatchState", + "StorageModule", + "StorageResult", +] diff --git a/grid2op/Environment/dispatch/baseRedispatchSolver.py b/grid2op/Environment/dispatch/baseRedispatchSolver.py new file mode 100644 index 00000000..1a0ebc34 --- /dev/null +++ b/grid2op/Environment/dispatch/baseRedispatchSolver.py @@ -0,0 +1,41 @@ +# Copyright (c) 2026, 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 __future__ import annotations + +import copy +from abc import ABC, abstractmethod +from typing import Optional + +from .dispatchTypes import RedispatchConstraints, RedispatchState + + +class BaseRedispatchSolver(ABC): + def __init__(self) -> None: + self.env = None + + def bind(self, env): + self.env = env + return self + + def copy_for_env(self, env): + new_obj = copy.copy(self) + new_obj.bind(env) + return new_obj + + @abstractmethod + def solve( + self, + constraints: RedispatchConstraints, + state: RedispatchState, + ) -> Optional[Exception]: + """Compute state.actual_dispatch in-place. Return None on success.""" + + @abstractmethod + def reset(self, state: RedispatchState) -> None: + """Reset redispatch-related state.""" diff --git a/grid2op/Environment/dispatch/curtailmentModule.py b/grid2op/Environment/dispatch/curtailmentModule.py new file mode 100644 index 00000000..642043eb --- /dev/null +++ b/grid2op/Environment/dispatch/curtailmentModule.py @@ -0,0 +1,75 @@ +# Copyright (c) 2026, 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 __future__ import annotations + +import numpy as np + +from grid2op.dtypes import dt_float + +from .dispatchTypes import CurtailmentResult, RedispatchState + + +class CurtailmentModule: + def __init__(self, env) -> None: + self.env = env + + def reset(self, state: RedispatchState) -> None: + state.limit_curtailment[self.env.gen_renewable] = 1.0 + state.limit_curtailment_prev[self.env.gen_renewable] = 1.0 + state.gen_before_curtailment[:] = 0.0 + state.sum_curtailment_mw = dt_float(0.0) + state.sum_curtailment_mw_prev = dt_float(0.0) + state.limited_before = dt_float(0.0) + + def _update_env_action(self, new_p) -> None: + if "prod_p" in self.env._env_modification._dict_inj: + self.env._env_modification._dict_inj["prod_p"][:] = new_p + else: + self.env._env_modification._dict_inj["prod_p"] = 1.0 * new_p + self.env._env_modification._modif_inj = True + + def _update_limits(self, action, state: RedispatchState) -> None: + curtailment_act = 1.0 * action._curtail + ind_curtailed_in_act = (curtailment_act != -1.0) & self.env.gen_renewable + state.limit_curtailment_prev[:] = state.limit_curtailment + state.limit_curtailment[ind_curtailed_in_act] = curtailment_act[ + ind_curtailed_in_act + ] + + def _apply_limits(self, new_p, curtailment_vect): + gen_curtailed = np.abs(curtailment_vect - 1.0) >= 1e-7 + max_action = self.env.gen_pmax[gen_curtailed] * curtailment_vect[gen_curtailed] + new_p[gen_curtailed] = np.minimum(max_action, new_p[gen_curtailed]) + return gen_curtailed + + def compute(self, action, new_p, state: RedispatchState) -> CurtailmentResult: + if self.env.redispatching_unit_commitment_availble and ( + action._modif_curtailment + or (np.abs(state.limit_curtailment - 1.0) >= 1e-7).any() + ): + self._update_limits(action, state) + gen_curtailed = self._apply_limits(new_p, state.limit_curtailment) + tmp_sum_curtailment_mw = dt_float( + new_p[gen_curtailed].sum() + - state.gen_before_curtailment[gen_curtailed].sum() + ) + state.sum_curtailment_mw = ( + tmp_sum_curtailment_mw - state.sum_curtailment_mw_prev + ) + state.sum_curtailment_mw_prev = tmp_sum_curtailment_mw + self._update_env_action(new_p) + else: + state.sum_curtailment_mw = -state.sum_curtailment_mw_prev + state.sum_curtailment_mw_prev = dt_float(0.0) + gen_curtailed = np.abs(state.limit_curtailment - 1.0) >= 1e-7 + + return CurtailmentResult( + sum_curtailment_mw=state.sum_curtailment_mw, + gen_curtailed=gen_curtailed, + ) diff --git a/grid2op/Environment/dispatch/defaultRedispatchSolver.py b/grid2op/Environment/dispatch/defaultRedispatchSolver.py new file mode 100644 index 00000000..260a828d --- /dev/null +++ b/grid2op/Environment/dispatch/defaultRedispatchSolver.py @@ -0,0 +1,431 @@ +# Copyright (c) 2026, 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 __future__ import annotations + +from typing import List, Optional + +import numpy as np +from scipy.optimize import LinearConstraint, minimize + +from grid2op.Action import BaseAction +from grid2op.Exceptions import ( + GeneratorTurnedOffTooSoon, + GeneratorTurnedOnTooSoon, + IllegalRedispatching, + ImpossibleRedispatching, +) + +from .baseRedispatchSolver import BaseRedispatchSolver +from .dispatchTypes import RedispatchConstraints, RedispatchState + + +class DefaultRedispatchSolver(BaseRedispatchSolver): + def reset(self, state: RedispatchState) -> None: + state.target_dispatch[:] = 0.0 + state.already_modified_gen[:] = False + state.actual_dispatch[:] = 0.0 + state.gen_uptime[:] = 0 + state.gen_downtime[:] = 0 + state.gen_activeprod_t[:] = 0.0 + state.gen_activeprod_t_redisp[:] = 0.0 + + def _update_target_dispatch(self, action: BaseAction, state: RedispatchState): + if not action._modif_redispatch: + return state.already_modified_gen + + redisp_act_orig = action._redispatch + is_redisped = np.abs(redisp_act_orig) > 1e-7 + state.target_dispatch[state.already_modified_gen] += redisp_act_orig[ + state.already_modified_gen + ] + first_modified = (~state.already_modified_gen) & is_redisped + state.target_dispatch[first_modified] = ( + state.actual_dispatch[first_modified] + redisp_act_orig[first_modified] + ) + state.already_modified_gen[is_redisped] = True + return state.already_modified_gen + + def _validate_dispatch( + self, + action: BaseAction, + new_p, + already_modified_gen, + state: RedispatchState, + ): + cls = type(self.env) + except_ = None + info_: List[dict] = [] + valid = True + + if action._modif_redispatch: + redisp_act_orig = action._redispatch.copy() + else: + redisp_act_orig = None + + if ( + (redisp_act_orig is not None and (np.abs(redisp_act_orig) <= 1e-7).all()) + and (np.abs(state.target_dispatch) <= 1e-7).all() + and (np.abs(state.actual_dispatch) <= 1e-7).all() + ): + return valid, except_, info_ + + if redisp_act_orig is None: + redisp_act_orig = type(action)._build_attr("_redispatch") + + if (state.target_dispatch > cls.gen_pmax - cls.gen_pmin).any(): + cond_invalid = state.target_dispatch > cls.gen_pmax - cls.gen_pmin + except_ = IllegalRedispatching( + "You cannot ask for a dispatch higher than pmax - pmin [it would be always " + "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((cond_invalid).nonzero()[0]) + ) + state.target_dispatch -= redisp_act_orig + return valid, except_, info_ + if (state.target_dispatch < cls.gen_pmin - cls.gen_pmax).any(): + cond_invalid = state.target_dispatch < cls.gen_pmin - cls.gen_pmax + except_ = IllegalRedispatching( + "You cannot ask for a dispatch lower than pmin - pmax [it would be always " + "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((cond_invalid).nonzero()[0]) + ) + state.target_dispatch -= redisp_act_orig + return valid, except_, info_ + + if (redisp_act_orig[np.abs(new_p) <= 1e-7]).any() and self.env._forbid_dispatch_off: + except_ = IllegalRedispatching("Impossible to dispatch a turned off generator") + state.target_dispatch -= redisp_act_orig + return valid, except_, info_ + + if self.env._forbid_dispatch_off: + redisp_act_orig_cut = redisp_act_orig.copy() + 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": ( + redisp_act_orig_cut != redisp_act_orig + ).nonzero()[0] + } + ) + return valid, except_, info_ + + def _detect_infeasible_dispatch( + self, + constraints: RedispatchConstraints, + incr_in_chronics, + avail_down, + avail_up, + state: RedispatchState, + ): + except_ = None + sum_move = ( + incr_in_chronics.sum() + + constraints.amount_storage_mw + - constraints.sum_curtailment_mw + + constraints.detached_mw + ) + avail_down_sum = avail_down.sum() + avail_up_sum = avail_up.sum() + gen_setpoint = state.gen_activeprod_t_redisp[self.env.gen_redispatchable] + if sum_move > avail_up_sum: + msg = self.env.DETAILED_REDISP_ERR_MSG.format( + sum_move=sum_move, + avail_up_sum=avail_up_sum, + gen_setpoint=np.round(gen_setpoint, decimals=2), + ramp_up=self.env.gen_max_ramp_up[self.env.gen_redispatchable], + gen_pmax=self.env.gen_pmax[self.env.gen_redispatchable], + avail_up=np.round(avail_up, decimals=2), + increase="increase", + decrease="decrease", + maximum="maximum", + pmax="pmax", + max_ramp_up="max_ramp_up", + ) + except_ = ImpossibleRedispatching(msg) + elif sum_move < avail_down_sum: + msg = self.env.DETAILED_REDISP_ERR_MSG.format( + sum_move=sum_move, + avail_up_sum=avail_down_sum, + gen_setpoint=np.round(gen_setpoint, decimals=2), + ramp_up=self.env.gen_max_ramp_down[self.env.gen_redispatchable], + gen_pmax=self.env.gen_pmin[self.env.gen_redispatchable], + avail_up=np.round(avail_up, decimals=2), + increase="decrease", + decrease="increase", + maximum="minimum", + pmax="pmin", + max_ramp_up="max_ramp_down", + ) + except_ = ImpossibleRedispatching(msg) + return except_ + + def _solve(self, constraints: RedispatchConstraints, state: RedispatchState): + except_ = None + cls = type(self.env) + this_dt_float = float + new_p = constraints.new_p + if self.env.nb_time_step == 0: + state.gen_activeprod_t_redisp[:] = new_p + + gen_participating = constraints.gen_participating.copy() + incr_in_chronics = new_p - (state.gen_activeprod_t_redisp - state.actual_dispatch) + + p_min_down = cls.gen_pmin[gen_participating] - state.gen_activeprod_t_redisp[gen_participating] + avail_down = np.maximum(p_min_down, -cls.gen_max_ramp_down[gen_participating]) + p_max_up = cls.gen_pmax[gen_participating] - state.gen_activeprod_t_redisp[gen_participating] + avail_up = np.minimum(p_max_up, cls.gen_max_ramp_up[gen_participating]) + except_ = self._detect_infeasible_dispatch( + constraints, + incr_in_chronics[gen_participating], + avail_down, + avail_up, + state, + ) + if except_ is not None: + if ( + self.env._parameters.IGNORE_MIN_UP_DOWN_TIME + and self.env._parameters.ALLOW_DISPATCH_GEN_SWITCH_OFF + ): + gen_participating_tmp = self.env.gen_redispatchable.copy() + if cls.detachment_is_allowed: + gen_participating_tmp[constraints.gen_detached] = False + p_min_down_tmp = ( + cls.gen_pmin[gen_participating_tmp] + - state.gen_activeprod_t_redisp[gen_participating_tmp] + ) + avail_down_tmp = np.maximum( + p_min_down_tmp, -cls.gen_max_ramp_down[gen_participating_tmp] + ) + p_max_up_tmp = ( + cls.gen_pmax[gen_participating_tmp] + - state.gen_activeprod_t_redisp[gen_participating_tmp] + ) + avail_up_tmp = np.minimum( + p_max_up_tmp, cls.gen_max_ramp_up[gen_participating_tmp] + ) + except_tmp = self._detect_infeasible_dispatch( + constraints, + incr_in_chronics[gen_participating_tmp], + avail_down_tmp, + avail_up_tmp, + state, + ) + if except_tmp is None: + gen_participating = gen_participating_tmp + except_ = None + else: + return except_tmp + else: + return except_ + + target_vals = state.target_dispatch[gen_participating] - state.actual_dispatch[gen_participating] + already_modified_gen_me = state.already_modified_gen[gen_participating] + target_vals_me = target_vals[already_modified_gen_me] + nb_dispatchable = gen_participating.sum() + tmp_zeros = np.zeros((1, nb_dispatchable), dtype=this_dt_float) + coeffs = 1.0 / (self.env.gen_max_ramp_up + self.env.gen_max_ramp_down + self.env._epsilon_poly) + weights = np.ones(nb_dispatchable) * coeffs[gen_participating] + weights /= weights.sum() + + if target_vals_me.shape[0] == 0: + already_modified_gen_me[:] = True + target_vals_me = target_vals[already_modified_gen_me] + + scale_x = max(np.max(np.abs(state.actual_dispatch)), 1.0) + scale_x = this_dt_float(scale_x) + target_vals_me_optim = 1.0 * (target_vals_me / scale_x) + target_vals_me_optim = target_vals_me_optim.astype(this_dt_float) + + scale_objective = max(0.5 * np.abs(target_vals_me_optim).sum() ** 2, 1.0) + scale_objective = np.round(scale_objective, decimals=4) + scale_objective = this_dt_float(scale_objective) + + mat_sum_0_no_turn_on = np.ones((1, nb_dispatchable), dtype=this_dt_float) + const_sum_0_no_turn_on = ( + np.zeros(1, dtype=this_dt_float) + + constraints.amount_storage_mw + - constraints.sum_curtailment_mw + + constraints.detached_mw + ) + + new_p_th = new_p[gen_participating] + state.actual_dispatch[gen_participating] + p_min_const = self.env.gen_pmin[gen_participating] - new_p_th + ramp_down_const = -self.env.gen_max_ramp_down[gen_participating] - incr_in_chronics[gen_participating] + min_disp = np.maximum(p_min_const, ramp_down_const).astype(this_dt_float) + + p_max_const = self.env.gen_pmax[gen_participating] - new_p_th + ramp_up_const = self.env.gen_max_ramp_up[gen_participating] - incr_in_chronics[gen_participating] + max_disp = np.minimum(p_max_const, ramp_up_const).astype(this_dt_float) + + added = 0.5 * self.env._epsilon_poly + equality_const = LinearConstraint( + mat_sum_0_no_turn_on, + const_sum_0_no_turn_on / scale_x, + const_sum_0_no_turn_on / scale_x, + ) + mat_pmin_max_ramps = np.eye(nb_dispatchable) + ineq_const = LinearConstraint( + mat_pmin_max_ramps, + (min_disp - added) / scale_x, + (max_disp + added) / scale_x, + ) + + x0 = np.zeros(gen_participating.sum(), dtype=this_dt_float) + if (np.abs(state.target_dispatch) >= 1e-7).any() or state.already_modified_gen.any(): + gen_for_x0 = np.abs(state.target_dispatch[gen_participating]) >= 1e-7 + gen_for_x0 |= state.already_modified_gen[gen_participating] + x0[gen_for_x0] = ( + state.target_dispatch[gen_participating][gen_for_x0] + - state.actual_dispatch[gen_participating][gen_for_x0] + ) / scale_x + can_adjust = np.abs(x0) <= 1e-7 + if can_adjust.any(): + init_sum = x0.sum() + denom_adjust = (1.0 / weights[can_adjust]).sum() + if denom_adjust <= 1e-2: + denom_adjust = 1.0 + x0[can_adjust] = -init_sum / (weights[can_adjust] * denom_adjust) + else: + x0 -= state.actual_dispatch[gen_participating] / scale_x + + def target(actual_dispatchable): + quad_ = ( + actual_dispatchable[already_modified_gen_me] - target_vals_me_optim + ) ** 2 + coeffs_quads = weights[already_modified_gen_me] * quad_ + coeffs_quads_const = coeffs_quads.sum() + coeffs_quads_const /= scale_objective + return coeffs_quads_const + + def jac(actual_dispatchable): + res_jac = 1.0 * tmp_zeros + res_jac[0, already_modified_gen_me] = ( + 2.0 + * weights[already_modified_gen_me] + * (actual_dispatchable[already_modified_gen_me] - target_vals_me_optim) + ) + res_jac /= scale_objective + return res_jac.reshape(-1) + + res = minimize( + target, + x0, + method="SLSQP", + constraints=[equality_const, ineq_const], + options={ + "eps": max(this_dt_float(self.env._epsilon_poly / scale_x), 1e-6), + "ftol": max(this_dt_float(self.env._epsilon_poly / scale_x), 1e-6), + "disp": False, + }, + jac=jac, + ) + if res.success: + state.actual_dispatch[gen_participating] += res.x * scale_x + else: + mat_const = np.concatenate((mat_sum_0_no_turn_on, mat_pmin_max_ramps)) + downs = np.concatenate( + (const_sum_0_no_turn_on / scale_x, (min_disp - added) / scale_x) + ) + ups = np.concatenate( + (const_sum_0_no_turn_on / scale_x, (max_disp + added) / scale_x) + ) + vals = np.matmul(mat_const, res.x) + ok_down = np.all(vals - downs >= -self.env._tol_poly) + ok_up = np.all(vals - ups <= self.env._tol_poly) + if ok_up and ok_down: + state.actual_dispatch[gen_participating] += res.x * scale_x + else: + error_dispatch = ( + "Redispatching automaton terminated with error (no more information available " + 'at this point):\n"{}"'.format(res.message) + ) + except_ = ImpossibleRedispatching(error_dispatch) + return except_ + + def solve( + self, + constraints: RedispatchConstraints, + state: RedispatchState, + ) -> Optional[Exception]: + if not self.env._parameters.ENV_DOES_REDISPATCHING: + state.actual_dispatch[:] = state.target_dispatch.copy() + return None + mismatch = np.abs(state.actual_dispatch - state.target_dispatch) + if ( + np.abs((state.actual_dispatch).sum()) >= self.env._tol_poly + or np.max(mismatch) >= self.env._tol_poly + or np.abs(state.amount_storage) >= self.env._tol_poly + or np.abs(state.sum_curtailment_mw) >= self.env._tol_poly + or np.abs(state.detached_elements_mw) >= self.env._tol_poly + ): + return self._solve(constraints, state) + return None + + def _check_updown_times(self, gen_up_before, redisp_act, state: RedispatchState): + except_ = None + cls = type(self.env) + gen_up_after = state.gen_activeprod_t.copy() + if "prod_p" in self.env._env_modification._dict_inj: + tmp = self.env._env_modification._dict_inj["prod_p"] + indx_ok = np.isfinite(tmp) + gen_up_after[indx_ok] = self.env._env_modification._dict_inj["prod_p"][indx_ok] + gen_up_after += redisp_act + gen_up_after = np.abs(gen_up_after) > 1e-7 + + gen_disconnected_this = gen_up_before & (~gen_up_after) + gen_connected_this_timestep = (~gen_up_before) & gen_up_after & ~self.env._gens_detached + gen_still_connected = gen_up_before & gen_up_after + gen_still_disconnected = ((~gen_up_before) & (~gen_up_after)) | self.env._gens_detached + if ( + not self.env._ignore_min_up_down_times + and ( + state.gen_downtime[gen_connected_this_timestep] + < cls.gen_min_downtime[gen_connected_this_timestep] + ).any() + ): + id_gen = ( + state.gen_downtime[gen_connected_this_timestep] + < cls.gen_min_downtime[gen_connected_this_timestep] + ) + id_gen = (id_gen).nonzero()[0] + id_gen = (gen_connected_this_timestep[id_gen]).nonzero()[0] + except_ = GeneratorTurnedOnTooSoon( + "Some generator has been connected too early ({})".format(id_gen) + ) + return except_ + else: + state.gen_downtime[gen_connected_this_timestep] = -1 + state.gen_uptime[gen_connected_this_timestep] = 0 + + if ( + not self.env._ignore_min_up_down_times + and ( + state.gen_uptime[gen_disconnected_this] + < cls.gen_min_uptime[gen_disconnected_this] + ).any() + ): + id_gen = ( + state.gen_uptime[gen_disconnected_this] + < cls.gen_min_uptime[gen_disconnected_this] + ) + id_gen = (id_gen).nonzero()[0] + id_gen = (gen_disconnected_this[id_gen]).nonzero()[0] + except_ = GeneratorTurnedOffTooSoon( + "Some generator has been disconnected too early ({})".format(id_gen) + ) + return except_ + else: + state.gen_downtime[gen_disconnected_this] = 0 + state.gen_uptime[gen_disconnected_this] = -1 + + state.gen_uptime[gen_still_connected] += 1 + state.gen_downtime[gen_still_disconnected] += 1 + return except_ diff --git a/grid2op/Environment/dispatch/detachmentModule.py b/grid2op/Environment/dispatch/detachmentModule.py new file mode 100644 index 00000000..70e88c2f --- /dev/null +++ b/grid2op/Environment/dispatch/detachmentModule.py @@ -0,0 +1,55 @@ +# Copyright (c) 2026, 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 __future__ import annotations + +from grid2op.dtypes import dt_float + +from .dispatchTypes import DetachmentResult, RedispatchState + + +class DetachmentModule: + def __init__(self, env) -> None: + self.env = env + + def feed_data(self, new_p_th, state: RedispatchState) -> None: + self.env._prev_gen_p[:] = new_p_th + self.env._aux_retrieve_modif_act(self.env._prev_load_p, self.env._env_modification, "load_p") + self.env._aux_retrieve_modif_act(self.env._prev_load_q, self.env._env_modification, "load_q") + + def compute( + self, + new_p, + new_p_th, + state: RedispatchState, + backend_action=None, + ) -> DetachmentResult: + if backend_action is None: + backend_action = self.env._backend_action + gen_detached_user = backend_action.get_gen_detached() + load_detached_user = backend_action.get_load_detached() + + mw_gen_lost_this = new_p[gen_detached_user].sum() + mw_load_lost_this = self.env._prev_load_p[load_detached_user].sum() + total_power_lost = -mw_gen_lost_this + mw_load_lost_this + state.detached_elements_mw = dt_float( + -total_power_lost + + state.actual_dispatch[gen_detached_user].sum() + - state.detached_elements_mw_prev + ) + state.detached_elements_mw_prev = dt_float(-total_power_lost) + + new_p[gen_detached_user] = 0.0 + new_p_th[gen_detached_user] = 0.0 + state.actual_dispatch[gen_detached_user] = 0.0 + return DetachmentResult( + detached_mw=state.detached_elements_mw, + gen_detached=gen_detached_user, + new_p=new_p, + new_p_th=new_p_th, + ) diff --git a/grid2op/Environment/dispatch/dispatchTypes.py b/grid2op/Environment/dispatch/dispatchTypes.py new file mode 100644 index 00000000..fcff1e6a --- /dev/null +++ b/grid2op/Environment/dispatch/dispatchTypes.py @@ -0,0 +1,145 @@ +# Copyright (c) 2026, 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 __future__ import annotations + +from dataclasses import dataclass +from typing import Optional, TYPE_CHECKING + +import numpy as np + +from grid2op.dtypes import dt_bool, dt_float, dt_int + +if TYPE_CHECKING: + from grid2op.Environment.baseEnv import BaseEnv + + +@dataclass +class StorageResult: + amount_storage_mw: np.float64 = dt_float(0.0) + storage_power: Optional[np.ndarray] = None + + +@dataclass +class CurtailmentResult: + sum_curtailment_mw: np.float64 = dt_float(0.0) + gen_curtailed: Optional[np.ndarray] = None + + +@dataclass +class DetachmentResult: + detached_mw: np.float64 = dt_float(0.0) + gen_detached: Optional[np.ndarray] = None + new_p: Optional[np.ndarray] = None + new_p_th: Optional[np.ndarray] = None + + +@dataclass +class GuardInfo: + total_curtailment: np.float64 = dt_float(0.0) + total_storage: np.float64 = dt_float(0.0) + updated_env_action: bool = False + + +@dataclass +class RedispatchState: + target_dispatch: Optional[np.ndarray] = None + already_modified_gen: Optional[np.ndarray] = None + actual_dispatch: Optional[np.ndarray] = None + gen_uptime: Optional[np.ndarray] = None + gen_downtime: Optional[np.ndarray] = None + gen_activeprod_t: Optional[np.ndarray] = None + gen_activeprod_t_redisp: Optional[np.ndarray] = None + storage_current_charge: Optional[np.ndarray] = None + storage_previous_charge: Optional[np.ndarray] = None + action_storage: Optional[np.ndarray] = None + amount_storage: np.float64 = dt_float(0.0) + amount_storage_prev: np.float64 = dt_float(0.0) + storage_power: Optional[np.ndarray] = None + storage_power_prev: Optional[np.ndarray] = None + limit_curtailment: Optional[np.ndarray] = None + limit_curtailment_prev: Optional[np.ndarray] = None + gen_before_curtailment: Optional[np.ndarray] = None + sum_curtailment_mw: np.float64 = dt_float(0.0) + sum_curtailment_mw_prev: np.float64 = dt_float(0.0) + detached_elements_mw: np.float64 = dt_float(0.0) + detached_elements_mw_prev: np.float64 = dt_float(0.0) + limited_before: np.float64 = dt_float(0.0) + + @classmethod + def allocate(cls, n_gen: int, n_storage: int) -> "RedispatchState": + return cls( + target_dispatch=np.zeros(n_gen, dtype=dt_float), + already_modified_gen=np.zeros(n_gen, dtype=dt_bool), + actual_dispatch=np.zeros(n_gen, dtype=dt_float), + gen_uptime=np.zeros(n_gen, dtype=dt_int), + gen_downtime=np.zeros(n_gen, dtype=dt_int), + gen_activeprod_t=np.zeros(n_gen, dtype=dt_float), + gen_activeprod_t_redisp=np.zeros(n_gen, dtype=dt_float), + storage_current_charge=np.zeros(n_storage, dtype=dt_float), + storage_previous_charge=np.zeros(n_storage, dtype=dt_float), + action_storage=np.zeros(n_storage, dtype=dt_float), + storage_power=np.zeros(n_storage, dtype=dt_float), + storage_power_prev=np.zeros(n_storage, dtype=dt_float), + limit_curtailment=np.ones(n_gen, dtype=dt_float), + limit_curtailment_prev=np.ones(n_gen, dtype=dt_float), + gen_before_curtailment=np.zeros(n_gen, dtype=dt_float), + ) + + +@dataclass(frozen=True) +class RedispatchConstraints: + new_p: np.ndarray + gen_activeprod_t: np.ndarray + target_dispatch: np.ndarray + gen_participating: np.ndarray + amount_storage_mw: np.float64 + sum_curtailment_mw: np.float64 + detached_mw: np.float64 + pmin: np.ndarray + pmax: np.ndarray + ramp_up: np.ndarray + ramp_down: np.ndarray + redispatchable_mask: np.ndarray + gen_detached: np.ndarray + + @classmethod + def from_results( + cls, + storage_result: Optional[StorageResult], + curtail_result: Optional[CurtailmentResult], + detach_result: Optional[DetachmentResult], + new_p: np.ndarray, + state: RedispatchState, + env: "BaseEnv", + ) -> "RedispatchConstraints": + cls_env = type(env) + gen_detached = env._backend_action.get_gen_detached() + gen_participating = ( + (new_p > 0.0) + | (np.abs(state.actual_dispatch) >= 1e-7) + | (state.target_dispatch != state.actual_dispatch) + ) + gen_participating[~cls_env.gen_redispatchable] = False + if cls_env.detachment_is_allowed: + gen_participating[gen_detached] = False + return cls( + new_p=new_p, + gen_activeprod_t=state.gen_activeprod_t, + target_dispatch=state.target_dispatch, + gen_participating=gen_participating, + amount_storage_mw=dt_float(state.amount_storage), + sum_curtailment_mw=dt_float(state.sum_curtailment_mw), + detached_mw=dt_float(state.detached_elements_mw), + pmin=cls_env.gen_pmin, + pmax=cls_env.gen_pmax, + ramp_up=cls_env.gen_max_ramp_up, + ramp_down=cls_env.gen_max_ramp_down, + redispatchable_mask=cls_env.gen_redispatchable, + gen_detached=gen_detached, + ) diff --git a/grid2op/Environment/dispatch/feasibilityGuard.py b/grid2op/Environment/dispatch/feasibilityGuard.py new file mode 100644 index 00000000..db615f58 --- /dev/null +++ b/grid2op/Environment/dispatch/feasibilityGuard.py @@ -0,0 +1,158 @@ +# Copyright (c) 2026, 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 __future__ import annotations + +import numpy as np + +from grid2op.dtypes import dt_float + +from .dispatchTypes import CurtailmentResult, GuardInfo, RedispatchState, StorageResult + + +class FeasibilityGuard: + def __init__(self, env) -> None: + self.env = env + + def _compute_ramp_budget(self, new_p, state: RedispatchState): + th_max = np.minimum( + state.gen_activeprod_t_redisp[self.env.gen_redispatchable] + + self.env.gen_max_ramp_up[self.env.gen_redispatchable], + self.env.gen_pmax[self.env.gen_redispatchable], + ) + th_min = np.maximum( + state.gen_activeprod_t_redisp[self.env.gen_redispatchable] + - self.env.gen_max_ramp_down[self.env.gen_redispatchable], + self.env.gen_pmin[self.env.gen_redispatchable], + ) + + max_total_up = (th_max - new_p[self.env.gen_redispatchable]).sum() + max_total_down = (th_min - new_p[self.env.gen_redispatchable]).sum() + return max_total_down, max_total_up + + def _readjust_curtailment( + self, + total_curtailment, + new_p_th, + new_p, + state: RedispatchState, + ) -> None: + state.sum_curtailment_mw += total_curtailment + state.sum_curtailment_mw_prev += total_curtailment + if total_curtailment > self.env._tol_poly: + curtailed = new_p_th - new_p + else: + new_p_with_previous_curtailment = 1.0 * new_p_th + self.env._curtailment_module._apply_limits( + new_p_with_previous_curtailment, state.limit_curtailment_prev + ) + curtailed = new_p_th - new_p_with_previous_curtailment + + curt_sum = curtailed.sum() + if abs(curt_sum) > self.env._tol_poly: + curtailed[~self.env.gen_renewable] = 0.0 + curtailed *= total_curtailment / curt_sum + new_p[self.env.gen_renewable] += curtailed[self.env.gen_renewable] + + def _readjust_storage(self, total_storage, state: RedispatchState) -> None: + new_act_storage = 1.0 * state.storage_power + sum_this_step = new_act_storage.sum() + if abs(total_storage) < abs(sum_this_step): + modif_storage = new_act_storage * total_storage / sum_this_step + else: + new_act_storage = 1.0 * state.storage_power_prev + sum_this_step = new_act_storage.sum() + if abs(sum_this_step) > 1e-1: + modif_storage = new_act_storage * total_storage / sum_this_step + else: + modif_storage = new_act_storage + + coeff_p_to_E = self.env.delta_time_seconds / 3600.0 + state.storage_power -= modif_storage + + is_discharging = state.storage_power < 0.0 + is_charging = state.storage_power > 0.0 + modif_storage[is_discharging] /= type(self.env).storage_discharging_efficiency[ + is_discharging + ] + modif_storage[is_charging] *= type(self.env).storage_charging_efficiency[ + is_charging + ] + + state.storage_current_charge -= coeff_p_to_E * modif_storage + state.amount_storage -= total_storage + state.amount_storage_prev -= total_storage + + def check_and_clamp( + self, + storage_result: StorageResult, + curtail_result: CurtailmentResult, + new_p, + new_p_th, + state: RedispatchState, + ) -> GuardInfo: + gen_redisp = self.env.gen_redispatchable + normal_increase = new_p - ( + state.gen_activeprod_t_redisp - state.actual_dispatch + ) + normal_increase = normal_increase[gen_redisp] + p_min_down = self.env.gen_pmin[gen_redisp] - state.gen_activeprod_t_redisp[gen_redisp] + avail_down = np.maximum(p_min_down, -self.env.gen_max_ramp_down[gen_redisp]) + p_max_up = self.env.gen_pmax[gen_redisp] - state.gen_activeprod_t_redisp[gen_redisp] + avail_up = np.minimum(p_max_up, self.env.gen_max_ramp_up[gen_redisp]) + + sum_move = normal_increase.sum() + state.amount_storage - state.sum_curtailment_mw + total_storage_curtail = state.amount_storage - state.sum_curtailment_mw + update_env_act = False + total_curtailment = dt_float(0.0) + total_storage = dt_float(0.0) + + if abs(total_storage_curtail) >= self.env._tol_poly: + too_much = 0.0 + if sum_move > avail_up.sum(): + too_much = dt_float(sum_move - avail_up.sum() + self.env._tol_poly) + state.limited_before = too_much + elif sum_move < avail_down.sum(): + too_much = dt_float(sum_move - avail_down.sum() - self.env._tol_poly) + state.limited_before = too_much + elif np.abs(state.limited_before) >= self.env._tol_poly: + update_env_act = True + too_much = min(avail_up.sum() - self.env._tol_poly, state.limited_before) + state.limited_before -= too_much + too_much = state.limited_before + + if abs(too_much) > self.env._tol_poly: + total_curtailment = dt_float( + -state.sum_curtailment_mw / total_storage_curtail * too_much + ) + total_storage = dt_float( + state.amount_storage / total_storage_curtail * too_much + ) + update_env_act = True + + if np.sign(total_curtailment) != np.sign(total_storage): + total_curtailment = ( + too_much + if np.sign(total_curtailment) == np.sign(too_much) + else 0.0 + ) + total_storage = ( + too_much if np.sign(total_storage) == np.sign(too_much) else 0.0 + ) + + self._readjust_curtailment(total_curtailment, new_p_th, new_p, state) + self._readjust_storage(total_storage, state) + + if update_env_act: + self.env._curtailment_module._update_env_action(new_p) + + return GuardInfo( + total_curtailment=dt_float(total_curtailment), + total_storage=dt_float(total_storage), + updated_env_action=update_env_act, + ) diff --git a/grid2op/Environment/dispatch/storageModule.py b/grid2op/Environment/dispatch/storageModule.py new file mode 100644 index 00000000..0274b93b --- /dev/null +++ b/grid2op/Environment/dispatch/storageModule.py @@ -0,0 +1,117 @@ +# Copyright (c) 2026, 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 __future__ import annotations + +import numpy as np + +from .dispatchTypes import RedispatchState, StorageResult + + +class StorageModule: + def __init__(self, env) -> None: + self.env = env + + def reset(self, state: RedispatchState) -> None: + if self.env.n_storage > 0: + tmp = self.env._parameters.INIT_STORAGE_CAPACITY * self.env.storage_Emax + if self.env._parameters.ACTIVATE_STORAGE_LOSS: + tmp += self.env.storage_loss * self.env.delta_time_seconds / 3600.0 + state.storage_previous_charge[:] = tmp + state.storage_current_charge[:] = tmp + state.storage_power[:] = 0.0 + state.storage_power_prev[:] = 0.0 + state.amount_storage = 0.0 + state.amount_storage_prev = 0.0 + + def withdraw_losses(self, state: RedispatchState) -> None: + if self.env._parameters.ACTIVATE_STORAGE_LOSS: + tmp_ = self.env.storage_loss * self.env.delta_time_seconds / 3600.0 + state.storage_current_charge -= tmp_ + state.storage_current_charge[:] = np.maximum( + state.storage_current_charge, 0.0 + ) + + def _clamp_too_high(self, delta_, indx_too_high, state: RedispatchState) -> None: + coeff_p_to_E = self.env.delta_time_seconds / 3600.0 + tmp_ = 1.0 / coeff_p_to_E * delta_ + if self.env._parameters.ACTIVATE_STORAGE_LOSS: + tmp_ /= self.env.storage_charging_efficiency[indx_too_high] + state.storage_power[indx_too_high] -= tmp_ + + def _clamp_too_low(self, delta_, indx_too_low, state: RedispatchState) -> None: + coeff_p_to_E = self.env.delta_time_seconds / 3600.0 + tmp_ = 1.0 / coeff_p_to_E * delta_ + if self.env._parameters.ACTIVATE_STORAGE_LOSS: + tmp_ *= self.env.storage_discharging_efficiency[indx_too_low] + state.storage_power[indx_too_low] -= tmp_ + + def compute(self, action_storage_power, state: RedispatchState) -> StorageResult: + state.storage_previous_charge[:] = state.storage_current_charge + storage_act = np.isfinite(action_storage_power) & ( + np.abs(action_storage_power) >= 1e-7 + ) + state.action_storage[:] = 0.0 + state.storage_power[:] = 0.0 + modif = False + coeff_p_to_E = self.env.delta_time_seconds / 3600.0 + if storage_act.any(): + modif = True + this_act_stor = action_storage_power[storage_act] + eff_ = np.ones(storage_act.sum()) + if self.env._parameters.ACTIVATE_STORAGE_LOSS: + fill_storage = this_act_stor > 0.0 + unfill_storage = this_act_stor < 0.0 + eff_[fill_storage] *= self.env.storage_charging_efficiency[storage_act][ + fill_storage + ] + eff_[unfill_storage] /= self.env.storage_discharging_efficiency[ + storage_act + ][unfill_storage] + state.storage_current_charge[storage_act] += this_act_stor * coeff_p_to_E * eff_ + state.action_storage[storage_act] += action_storage_power[storage_act] + state.storage_power[storage_act] = this_act_stor + + if modif: + indx_too_high = state.storage_current_charge > self.env.storage_Emax + if indx_too_high.any(): + delta_ = ( + state.storage_current_charge[indx_too_high] + - self.env.storage_Emax[indx_too_high] + ) + self._clamp_too_high(delta_, indx_too_high, state) + state.storage_current_charge[indx_too_high] = self.env.storage_Emax[ + indx_too_high + ] + + indx_too_low = state.storage_current_charge < self.env.storage_Emin + if indx_too_low.any(): + delta_ = ( + state.storage_current_charge[indx_too_low] + - self.env.storage_Emin[indx_too_low] + ) + self._clamp_too_low(delta_, indx_too_low, state) + state.storage_current_charge[indx_too_low] = self.env.storage_Emin[ + indx_too_low + ] + + state.storage_current_charge[:] = np.maximum( + state.storage_current_charge, self.env.storage_Emin + ) + state.amount_storage = state.storage_power.sum() + else: + state.amount_storage = 0.0 + + tmp = state.amount_storage + state.amount_storage -= state.amount_storage_prev + state.amount_storage_prev = tmp + self.withdraw_losses(state) + return StorageResult( + amount_storage_mw=state.amount_storage, + storage_power=state.storage_power, + ) diff --git a/grid2op/Environment/environment.py b/grid2op/Environment/environment.py index 2e0a7c85..b3389b7a 100644 --- a/grid2op/Environment/environment.py +++ b/grid2op/Environment/environment.py @@ -130,6 +130,7 @@ def __init__( _allow_loaded_backend=False, _local_dir_cls=None, # only set at the first call to `make(...)` after should be false _overload_name_multimix=None, + redispatch_solver=None, ): if other_rewards is None: other_rewards = {} @@ -179,6 +180,7 @@ def __init__( _is_test=_is_test, # is this created with "test=True" # TODO not implemented !! _local_dir_cls=_local_dir_cls, _read_from_local_dir=_read_from_local_dir, + redispatch_solver=redispatch_solver, ) if name == "unknown":