Source code for csnlp.nlps.constraints

from functools import cached_property
from typing import Literal, TypeVar, Union

import casadi as cs
import numpy as np
import numpy.typing as npt

from ..core.cache import invalidate_cache
from .variables import HasVariables

SymType = TypeVar("SymType", cs.SX, cs.MX)


[docs] class HasConstraints(HasVariables[SymType]): r"""Class for the creation and storage symbolic constraints for an NLP problem. It builds on top of :class:`HasVariables`, which handles both parameters and variables. Constraints are stored and managed in the canonical way. Equality constraints are in the form :math:`g(x,p) = 0` or :math:`G(x,p) = 0`, whereas inequality constraints are in the form :math:`h(x,p) \le 0` or :math:`H(x,p) \le 0`. Separated from the latter are the lower and upper bounds of the primary variables, which are also inequalities, i.e., :math:`lbx - x \le 0` and :math:`x - ubx \le 0`, but are passed differently to the CasADi solver interface. Moreover, the class is equipped with a mechanism to automatically remove lower and upper bounds that are redundant, i.e., when the lower bound is :math:`-\infty` and the upper bound is :math:`+\infty`, which often create numerical issues if passed to the solver as is. Parameters ---------- sym_type : {"SX", "MX"}, optional The CasADi symbolic variable type to use in the NLP, by default ``"SX"``. remove_redundant_x_bounds : bool, optional If ``True``, then redundant entries in :meth:`lbx` and :meth:`ubx` are removed when properties :meth:`h_lbx` and :meth:`h_ubx` are called. See these two properties for more details. By default, ``True``. """ def __init__( self, sym_type: Literal["SX", "MX"] = "SX", remove_redundant_x_bounds: bool = True, ) -> None: super().__init__(sym_type) self._dual_vars: dict[str, SymType] = {} self._pars: dict[str, SymType] = {} self._cons: dict[str, SymType] = {} self._g, self._lam_g = self._sym_type(0, 1), self._sym_type(0, 1) self._h, self._lam_h = self._sym_type(0, 1), self._sym_type(0, 1) self._lbx: np.ma.MaskedArray = np.ma.empty(0, fill_value=-np.inf) self._ubx: np.ma.MaskedArray = np.ma.empty(0, fill_value=+np.inf) self._lam_lbx = self._sym_type(0, 1) self._lam_ubx = self._sym_type(0, 1) self._remove_redundant_x_bounds = remove_redundant_x_bounds @property def lbx(self) -> np.ma.MaskedArray: """Gets the lower bound constraints of primary variables of the NLP scheme in masked vector form.""" return self._lbx @property def ubx(self) -> np.ma.MaskedArray: """Gets the upper bound constraints of primary variables of the NLP scheme in masked vector form.""" return self._ubx @property def lam_lbx(self) -> SymType: """Gets the dual variables of the primary variables lower bound constraints of the NLP scheme in vector form.""" return self._lam_lbx @property def lam_ubx(self) -> SymType: """Gets the dual variables of the primary variables upper bound constraints of the NLP scheme in vector form.""" return self._lam_ubx @property def g(self) -> SymType: """Gets the equality constraint expressions of the NLP scheme in vector form.""" return self._g @property def h(self) -> SymType: """Gets the inequality constraint expressions of the NLP scheme in vector form.""" return self._h @property def lam_g(self) -> SymType: """Gets the dual variables of the equality constraints of the NLP scheme in vector form.""" return self._lam_g @property def lam_h(self) -> SymType: """Gets the dual variables of the inequality constraints of the NLP scheme in vector form.""" return self._lam_h @property def ng(self) -> int: """Number of equality constraints in the NLP scheme.""" return self._g.shape[0] @property def nh(self) -> int: """Number of inequality constraints in the NLP scheme.""" return self._h.shape[0] @property def dual_variables(self) -> dict[str, SymType]: """Gets the dual variables of the NLP scheme.""" return self._dual_vars @property def constraints(self) -> dict[str, SymType]: """Gets the constraints of the NLP scheme.""" return self._cons @cached_property def nonmasked_lbx_idx(self) -> Union[slice, npt.NDArray[np.int64]]: """Gets the indices of non-masked entries in :meth:`lbx` (or the full slice).""" return ( slice(None) if np.ma.getmask(self._lbx) is np.ma.nomask else np.where(~np.ma.getmaskarray(self._lbx))[0] ) @cached_property def nonmasked_ubx_idx(self) -> Union[slice, npt.NDArray[np.int64]]: """Gets the indices of non-masked entries in :meth:`ubx` (or the full slice).""" return ( slice(None) if np.ma.getmask(self._ubx) is np.ma.nomask else np.where(~np.ma.getmaskarray(self._ubx))[0] ) @cached_property def h_lbx(self) -> SymType: """Gets the inequalities cor to :meth:`lbx`, i.e., :math:`lbx - x`.""" idx = self.nonmasked_lbx_idx return self._lbx.data[idx, None] - self._x[idx, :] @cached_property def h_ubx(self) -> SymType: """Gets the inequalities due to :meth:`ubx`, i.e., :math:`x - ubx`.""" idx = self.nonmasked_ubx_idx return self._x[idx, :] - self._ubx.data[idx, None] @cached_property def lam(self) -> SymType: """Gets the dual variables of the NLP scheme in vector form. Notes ----- The dual variables are vertically concatenated in the following order: :meth:`lam_g`, :meth:`lam_h`, :meth:`lam_lbx`, :meth:`lam_ubx`. """ return cs.vertcat(self._lam_g, self._lam_h, self._lam_lbx, self._lam_ubx) @cached_property def primal_dual(self) -> SymType: r"""Gets the collection of primal-dual variables (usually, denoted as ``y``) .. math:: y = \begin{bmatrix} x \\ \lambda \end{bmatrix}, where :math:`x` are the primal variables, and :math:`\lambda` the dual variables (see :meth:`x` and :meth:`lam`, respectively).""" return cs.vertcat(self._x, self.lam)
[docs] @invalidate_cache( nonmasked_lbx_idx, nonmasked_ubx_idx, h_lbx, h_ubx, lam, primal_dual ) def variable( self, name: str, shape: tuple[int, int] = (1, 1), discrete: bool = False, lb: Union[npt.ArrayLike, cs.DM] = -np.inf, ub: Union[npt.ArrayLike, cs.DM] = +np.inf, ) -> tuple[SymType, SymType, SymType]: r"""Adds a variable to the NLP problem. Parameters ---------- name : str Name of the new variable. Must not be already in use. shape : tuple of 2 ints, optional Shape of the new variable. By default, a scalar. discrete : bool, optional Flag indicating if the variable is discrete. Defaults to ``False``. lb, ub: array_like, optional Lower and upper bounds of the new variable. By default, unbounded. If provided, their dimension must be broadcastable. Returns ------- var : casadi.SX or MX The symbol of the new variable. lam_lb : casadi.SX or MX The symbol corresponding to the new variable lower bound constraint's multipliers. The shape of the multiplier is equal to the number of relevant lower bounds (i.e., :math:`\neq \pm \infty`), so it may differ from the shape of the variable itself. This behaviour can be disabled by setting ``remove_redundant_x_bounds=False``. lam_ub : casadi.SX or MX Same as above, for upper bound. Raises ------ ValueError Raises if there is already another variable with the same name; or if any element of the lower bound is larger than the corresponding lower bound element. """ lb = np.broadcast_to(lb, shape).reshape(-1, order="F") ub = np.broadcast_to(ub, shape).reshape(-1, order="F") if np.any(lb > ub): raise ValueError("Improper variable bounds.") var = super().variable(name, shape, discrete) mlb: np.ma.MaskedArray = np.ma.masked_array(lb, np.ma.nomask) mub: np.ma.MaskedArray = np.ma.masked_array(ub, np.ma.nomask) if self._remove_redundant_x_bounds: mlb.mask = np.isneginf(lb) mub.mask = np.isposinf(ub) self._lbx = np.ma.concatenate((self._lbx, mlb)) self._ubx = np.ma.concatenate((self._ubx, mub)) np.ma.set_fill_value(self._lbx, -np.inf) np.ma.set_fill_value(self._ubx, +np.inf) name_lam_lb = f"lam_lb_{name}" name_lam_ub = f"lam_ub_{name}" lam_lb = self._sym_type.sym(name_lam_lb, (~np.ma.getmaskarray(mlb)).sum()) lam_ub = self._sym_type.sym(name_lam_ub, (~np.ma.getmaskarray(mub)).sum()) self._dual_vars[name_lam_lb] = lam_lb self._dual_vars[name_lam_ub] = lam_ub self._lam_lbx = cs.veccat(self._lam_lbx, lam_lb) self._lam_ubx = cs.veccat(self._lam_ubx, lam_ub) return var, lam_lb, lam_ub
[docs] @invalidate_cache(lam, primal_dual) def constraint( self, name: str, lhs: Union[SymType, np.ndarray, cs.DM], op: Literal["==", ">=", "<="], rhs: Union[SymType, np.ndarray, cs.DM], soft: bool = False, simplify: bool = True, ) -> tuple[SymType, ...]: r"""Adds a constraint to the NLP problem, e.g., :math:`lhs \le rhs`. Parameters ---------- name : str Name of the new constraint. Must not be already in use. lhs : casadi.SX, MX, DM or numerical Symbolic expression of the left-hand term of the constraint. op: {"==", ">=", "<="} Operator relating the two terms. rhs : casadi.SX, MX, DM or numerical Symbolic expression of the right-hand term of the constraint. soft : bool, optional If ``True``, then a slack variable with appropriate size is added to the NLP to make the inequality constraint soft, and returned. This slack is automatically lower-bounded by 0, but remember to manually penalize its magnitude in the objective. Slacks are not supported for equality constraints. Defaults to ``False``. simplify : bool, optional Optionally simplies the constraint expression, but can be disabled. Returns ------- expr : casadi.SX or MX The constraint expression in canonical form, i.e., :math:`g(x,p) = 0` or :math:`h(x,p) \le 0`. lam : casadi.SX or MX The symbol corresponding to the constraint's multipliers. slack : casadi.SX or MX, optional The slack variable in case of ``soft=True``; otherwise, only a 2-tuple is returned. Raises ------ ValueError Raises if there is already another constraint with the same name; or if the operator is not recognized. NotImplementedError Raises if a soft equality constraint is requested. TypeError Raises if the constraint is not symbolic. """ if name in self._cons: raise ValueError(f"Constraint name '{name}' already exists.") expr = lhs - rhs if not isinstance(expr, (cs.SX, cs.MX)): raise TypeError("Constraint must be symbolic.") if simplify: expr = cs.cse(cs.simplify(expr)) shape = expr.shape if op == "==": is_eq = True if soft: raise NotImplementedError( "Soft equality constraints are not currently supported." ) elif op == "<=": is_eq = False elif op == ">=": is_eq = False expr = -expr else: raise ValueError(f"Unrecognized operator {op}.") if soft: slack = self.variable(f"slack_{name}", expr.shape, lb=0)[0] expr -= slack self._cons[name] = expr group, lam = ("_g", "_lam_g") if is_eq else ("_h", "_lam_h") name_lam = f"{lam[1:]}_{name}" lam_c = self._sym_type.sym(name_lam, shape[0] * shape[1]) self._dual_vars[name_lam] = lam_c setattr(self, group, cs.veccat(getattr(self, group), expr)) setattr(self, lam, cs.veccat(getattr(self, lam), lam_c)) return (expr, lam_c, slack) if soft else (expr, lam_c)
[docs] @invalidate_cache( nonmasked_lbx_idx, nonmasked_ubx_idx, h_lbx, h_ubx, lam, primal_dual ) def remove_variable_bounds( self, name: str, direction: Literal["lb", "ub", "both"], idx: Union[None, tuple[int, int], list[tuple[int, int]]] = None, ) -> None: """Removes one or more lower and/or upper bounds from the given variable Parameters ---------- name : str Name of the variable whose bounds must be modified direction : {"lb", "ub", "both"} Which bound to modify. idx : tuple[int, int] or a list of, optional A 2D index, or a list of 2D indices, of the variable entries whose corresponding lower/upper bounds must be removed, i.e., set to ``-/+ inf``. If not provided, then all the bounds for that variable are removed. Notes ----- This is a somewhat costly operation, so it is preferable to avoid creating in the first place constraints that will need to be eliminated. Moreover, this operation may compromise the results already obtained in, e.g., sensitivity analysis, because it changes the underlying NLP problem and there is no way to invalidate any user-arbitrary result obtained previously. """ n_rows, n_cols = self._vars[name].shape size = n_rows * n_cols if idx is None: idx_ = np.arange(size, dtype=int) else: # transform 2D indices to 1D (casadi column-wise) if isinstance(idx, tuple): idx = (idx,) idx_ = np.asarray([i[0] + i[1] * n_rows for i in idx], int) # add offset to skip variable created prior to the current offset = 0 for n, other_var in self._vars.items(): if n == name: break offset += other_var.shape[0] * other_var.shape[1] idx_ += offset # set lbx and ubx to -/+ inf if direction == "both" or direction == "lb": self._lbx[idx_] = -np.inf if direction == "both" or direction == "ub": self._ubx[idx_] = +np.inf if self._remove_redundant_x_bounds: # update masks lbx_mask = np.ma.getmaskarray(self._lbx) ubx_mask = np.ma.getmaskarray(self._ubx) lbx_mask[idx_] = ubx_mask[idx_] = True self._lbx.mask = lbx_mask self._ubx.mask = ubx_mask # remove obsolete multipliers directions = ("lb", "ub") if direction == "both" else (direction,) for lb_or_ub in directions: name_lam = f"lam_{lb_or_ub}_{name}" bounds = getattr(self, f"_{lb_or_ub}x")[offset : offset + size] mask = np.ma.getmaskarray(bounds) new_lam = self._sym_type.sym(name_lam, (~mask).sum()) # replace in dict and re-create vector of lbx/ubx multipliers self._dual_vars[name_lam] = new_lam all_lams = [ lam for n, lam in self._dual_vars.items() if n.startswith(f"lam_{lb_or_ub}") ] setattr(self, f"_lam_{lb_or_ub}x", cs.vvcat(all_lams))
[docs] @invalidate_cache(lam, primal_dual) def remove_constraints( self, name: str, idx: Union[None, tuple[int, int], list[tuple[int, int]]] = None, ) -> None: """Removes one or more (equality or inequality) constraints from the problem. Parameters ---------- name : str Name of the constraint to be removed. The name will be used to identify if the constraint is an inequality or an equality constraint. idx : tuple of 2 ints or a list of, optional A 2D index, or a list of 2D indices, of the constraint entries that must be removed. If not provided, then the constraint is removed entirely. Notes ----- This is a somewhat costly operation, so it is preferable to avoid creating in the first place constraints that will need to be eliminated. Moreover, this operation may compromise the results already obtained in, e.g., sensitivity analysis, because it changes the underlying NLP problem and there is no way to invalidate any user-arbitrary result obtained previously. """ old_con = self._cons.pop(name) group = "g" if f"lam_g_{name}" in self._dual_vars else "h" this_name_lam = f"lam_{group}_{name}" self._dual_vars.pop(this_name_lam) if idx is not None: # transform 2D indices to 1D (casadi column-wise) and keep only the # remaining indices n_rows = old_con.size1() if isinstance(idx, tuple): idx = (idx,) idx_to_remove = {i[0] + i[1] * n_rows for i in idx} # remove constraints and re-create corresponding multipliers old_con = cs.vec(old_con) # flatten the constraint - cannot do otherwise idx_to_keep = [i for i in range(old_con.size1()) if i not in idx_to_remove] if idx_to_keep: new_con = old_con[idx_to_keep] self._cons[name] = new_con self._dual_vars[this_name_lam] = self._sym_type.sym( this_name_lam, new_con.size1() ) # re-create constraints and multipliers vectors, and refresh the solver new_cons = [] new_lams = [] for n, con in self._cons.items(): name_lam = f"lam_{group}_{n}" if name_lam in self._dual_vars: new_cons.append(con) new_lams.append(self._dual_vars[name_lam]) setattr(self, f"_{group}", cs.vvcat(new_cons)) setattr(self, f"_lam_{group}", cs.vcat(new_lams)) if hasattr(self, "refresh_solver"): self.refresh_solver()