Source code for csnlp.core.solutions

"""Contains classes and methods to store the solution of an NLP problem after a call
to :meth:`csnlp.Nlp.solve` or :meth:`csnlp.multistart.MultistartNlp.solve_multi`."""

from collections.abc import Iterable as _Iterable
from functools import cached_property as _cached_property
from itertools import product as _product
from typing import TYPE_CHECKING, Optional, Union
from typing import Any as _Any
from typing import Protocol as _Protocol
from typing import TypeVar as _TypeVar

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

from .data import array2cs as _array2cs
from .data import cs2array as _cs2array

if TYPE_CHECKING:
    from ..nlps.nlp import Nlp

SymType = _TypeVar("SymType", cs.SX, cs.MX)
SymOrNumType = _TypeVar("SymOrNumType", cs.SX, cs.MX, cs.DM, int, float, np.ndarray)


def _is_infeas(status: str, solver_plugin: str) -> Optional[bool]:
    """Internal utility to compute whether the solver status indicates infeasibility."""
    # NLPs
    if solver_plugin == "ipopt":
        return status == "Infeasible_Problem_Detected"
    if solver_plugin in ("qrsqp", "sqpmethod"):
        return status == "Search_Direction_Becomes_Too_Small"
    # QPs
    if solver_plugin == "osqp":
        return "infeasible" in status
    if solver_plugin == "proxqp":
        return (
            status == "PROXQP_PRIMAL_INFEASIBLE" or status == "PROXQP_DUAL_INFEASIBLE"
        )
    if solver_plugin == "qpoases":
        return "infeasib" in status
    if solver_plugin == "qrqp":
        return status == "Failed to calculate search direction"
    # LPs
    if solver_plugin == "clp":
        return status.endswith("infeasible")
    # MIPs
    if solver_plugin in ("bonmin", "gurobi"):
        return status == "INFEASIBLE"
    if solver_plugin == "cbc":
        return "not feasible" in status
    if solver_plugin == "gurobi":
        return status == "INFEASIBLE" or status == "INF_OR_UNBD"
    if solver_plugin == "knitro":
        return "INFEAS" in status
    return None


[docs] class Solution(_Protocol[SymType]): """Class containing information on the solution of a solver's run for an instance of :class:`csnlp.Nlp`. Notes ----- This class is merely a protocol, and as such it just defines the interface of how a solution should look like, plus some minor implementations. """ @property def f(self) -> float: """Optimal value of the objective function.""" @property def p_sym(self) -> cs.DM: """Symbolical parameters in a vector.""" @property def p(self) -> cs.DM: """Values of the parameters for which this solver's solution was generated.""" @property def x_sym(self) -> SymType: """Symbolical primal variables in a vector.""" @property def x(self) -> cs.DM: """Optimal values of the primal variables in a vector.""" @property def lam_g_and_h_sym(self) -> cs.DM: """Symbolical the equality and inequality dual variables in a vector.""" @property def lam_g_and_h(self) -> cs.DM: """Optimal values of the equality and inequality dual variables in a vector.""" @property def lam_lbx_and_ubx_sym(self) -> cs.DM: """Symbolical lower- and upper-bounds dual variables in a vector.""" @property def lam_lbx_and_ubx(self) -> cs.DM: """Optimal values of the lower- and upper-bounds dual variables in a vector.""" @_cached_property def x_and_lam_and_p_sym(self) -> SymType: """Symbolical primal and dual variables and parameters in a vector.""" return cs.vertcat( self.x_sym, self.lam_g_and_h_sym, self.lam_lbx_and_ubx_sym, self.p_sym, ) @_cached_property def x_and_lam_and_p(self) -> cs.DM: """Optimal values of the primal and dual variables, as well as the parameters for which the solution was found, in a vector.""" return cs.vertcat( self.x, self.lam_g_and_h, self.lam_lbx_and_ubx, self.p, ) @property def vars(self) -> dict[str, SymType]: """Symbolical primal variables.""" return self._vars @property def vals(self) -> dict[str, cs.DM]: """Optimal values of the primal variables.""" @property def dual_vars(self) -> dict[str, SymType]: """Symbolical dual variables.""" return self._dual_vars @property def dual_vals(self) -> dict[str, cs.DM]: """Optimal values of the dual variables.""" @property def solver_plugin(self) -> str: """The solver plugin used to generate this solution.""" return self._solver_plugin @property def stats(self) -> dict[str, _Any]: """Statistics of the solver for this solution's run.""" return self._stats @property def status(self) -> str: """Gets the status of the solver at this solution.""" return self.stats["return_status"] @property def unified_return_status(self) -> str: """Gets the unified status of the solver at this solution.""" return self.stats["unified_return_status"] @property def success(self) -> bool: """Gets whether the solver's run was successful.""" return self.stats["success"] @_cached_property def infeasible(self) -> Optional[bool]: r"""Gets whether the solver status indicates infeasibility. If ``False``, it does not imply feasibility as the solver or its CasADi interface may have not detect it. For different solvers, the infeasibility status is stored in different ways. Here is a list of what I gathered so far. The solvers are grouped based on the type of problem they solve. An (F) next to the solver's name indicates that the the solver will crash the program if ``"error_on_fail": True`` and the solver fails. The ``status`` and ``unified_return_status`` can both be found in the solver's stats, or in this solution object. * NLPs - **fatrop**: unclear; better to return ``None`` for now - **ipopt**: ``status == "Infeasible_Problem_Detected"`` - **qrsqp** (F): ``status == "Search_Direction_Becomes_Too_Small"`` (dubious) - **sqpmethod** (F): ``status == "Search_Direction_Becomes_Too_Small"`` (dubious) * QPs - **ipqp** (F): no clear way to detect infeasibility; return ``None`` for now - **osqp** (F): ``unified_return_status == "SOLVER_RET_INFEASIBLE"`` or ``"infeasible" in status`` - **proxqp** (F): ``status == "PROXQP_PRIMAL_INFEASIBLE"`` or ``status == "PROXQP_DUAL_INFEASIBLE"`` - **qpoases** (F): ``"infeasib" in status`` - **qrqp** (F): ``status == "Failed to calculate search direction"`` * LPs - **clp** (F): ``status == "primal infeasible"`` or ``status == "dual infeasible"`` * Mixed-Iteger Problems (MIPs) - **bonmin** (F): ``status == "INFEASIBLE"`` - **cbc** (F): ``"not feasible" in status`` - **gurobi** (F): ``status == "INFEASIBLE"`` or ``status == "INF_OR_UNBD"`` - **knitro**: ``"INFEAS" in status`` """ return _is_infeas(self.status, self.solver_plugin) @property def barrier_parameter(self) -> float: """Gets the IPOPT barrier parameter at the optimal solution""" return self.stats["iterations"]["mu"][-1]
[docs] def value( self, expr: Union[SymType, np.ndarray], eval: bool = True ) -> Union[SymType, cs.DM]: """Computes the value of the expression substituting the values of this solution in the expression. Parameters ---------- expr : casadi.SX, MX or an array of these The symbolic expression to be evaluated at the solution's values. eval : bool, optional Evaluates numerically the new expression. By default, ``True``. See :meth:`csnlp.solutions.subsevalf` for more details. Returns ------- casadi.SX or MX or DM The expression evaluated with the solution's values. Raises ------ RuntimeError Raises if ``eval=True`` but there are symbolic variables that are still free. This can occur when there are symbols that are outside the solution's variables, and thus have not been substituted by a numerical value. """ return subsevalf( expr, self.x_and_lam_and_p_sym, self.x_and_lam_and_p, eval=eval )
[docs] @staticmethod def from_casadi_solution( sol_with_stats: dict[str, _Any], nlp: "Nlp[SymType]" ) -> "Solution[SymType]": """Creates a new solution from a CasADi solution, Parameters ---------- sol_with_stats : dict of (str, cs.DM) and one entry with stats, i.e., Any The solution dictionary from the CasADi solver, which contains the optimal values of the primal and dual variables, as well as the parameters, and the solver's statistics. nlp : Nlp[SymType] The NLP instance for which the solution was computed. Returns ------- Solution[SymType] The solution corresponding to the CasADi solution. """
[docs] @staticmethod def cmp_key(sol: "Solution[SymType]") -> tuple[bool, bool, float]: """Gets the comparison keys to compare a solution with another. This solution is strictly better if - it is feasible and the other is not, or - both are feasible or infeasible, and the current is successful and the other is not, or - both are successful or not, and the current has a lower optimal value than the other. To be used as ``key`` argument in, e.g., :func:`min` or :func:`sorted`. Returns ------- tuple of (bool, bool, float) A tuple with (is_infeasible, is_unsuccessful, f). """ is_infeas = _is_infeas(sol.status, sol.solver_plugin) if is_infeas is None: is_infeas = "infeas" in sol.status.lower() return is_infeas, not sol.success, sol.f
def __repr__(self) -> str: return ( f"{self.__class__.__name__}(f={self.f},success={self.success}," f"status={self.status})" )
[docs] class EagerSolution(Solution[SymType]): """Class containing information on the solution of a solver's run for an instance of :class:`csnlp.Nlp`. Parameters ---------- f : float Optimal value of the NLP at the solution. p_sym : cs.SX or MX Symbolic parameters in a vector. p : cs.DM Values of the parameters for which this solver's solution was generated. x_sym : SymType Symbolic primal variables in a vector. x : cs.DM Optimal values of the primal variables in a vector. lam_g_and_h_sym : cs.SX or MX Symbolic the equality and inequality dual variables in a vector. lam_g_and_h : cs.DM Optimal values of the equality and inequality dual variables in a vector. lam_lbx_and_ubx_sym : cs.SX or MX Symbolic lower- and upper-bounds dual variables in a vector. lam_lbx_and_ubx : cs.DM Optimal values of the lower- and upper-bounds dual variables in a vector. vars : dict of (str, cs.SX or MX) Symbolic primal variables. vals : dict of (str, cs.DM) Optimal values of the primal variables. dual_vars : dict of (str, cs.SX or MX) Symbolic dual variables. dual_vals : dict of (str, cs.DM) Optimal values of the dual variables. stats : dict Stats of the solver run that generated this solution. Notes ----- This class is an eager implementation of the :class:`csnlp.solutions.Solution`, where the values and properties of the solution are computed eagerly after the solver has finished its run. """ def __init__( self, f: float, p_sym: SymType, p: cs.DM, x_sym: SymType, x: cs.DM, lam_g_and_h_sym: SymType, lam_g_and_h: cs.DM, lam_lbx_and_ubx_sym: SymType, lam_lbx_and_ubx: cs.DM, vars: dict[str, SymType], vals: dict[str, cs.DM], dual_vars: dict[str, SymType], dual_vals: dict[str, cs.DM], stats: dict[str, _Any], solver_plugin: str, ) -> None: self._f = f self._p_sym = p_sym self._p = p self._x_sym = x_sym self._x = x self._lam_g_and_h_sym = lam_g_and_h_sym self._lam_g_and_h = lam_g_and_h self._lam_lbx_and_ubx_sym = lam_lbx_and_ubx_sym self._lam_lbx_and_ubx = lam_lbx_and_ubx self._vars = vars self._vals = vals self._dual_vars = dual_vars self._dual_vals = dual_vals self._stats = stats self._solver_plugin = solver_plugin @property def f(self) -> float: return self._f @property def p_sym(self) -> cs.DM: return self._p_sym @property def p(self) -> cs.DM: return self._p @property def x_sym(self) -> SymType: return self._x_sym @property def x(self) -> cs.DM: return self._x @property def lam_g_and_h_sym(self) -> cs.DM: return self._lam_g_and_h_sym @property def lam_g_and_h(self) -> cs.DM: return self._lam_g_and_h @property def lam_lbx_and_ubx_sym(self) -> cs.DM: return self._lam_lbx_and_ubx_sym @property def lam_lbx_and_ubx(self) -> cs.DM: return self._lam_lbx_and_ubx @property def vals(self) -> dict[str, cs.DM]: return self._vals @property def dual_vals(self) -> dict[str, cs.DM]: return self._dual_vals
[docs] @staticmethod def from_casadi_solution( sol_with_stats: dict[str, _Any], nlp: "Nlp[SymType]" ) -> "EagerSolution[SymType]": # stats and objective stats = sol_with_stats.pop("stats") sol: dict[str, cs.DM] = sol_with_stats # now the solution has only cs.DMs f = float(sol["f"]) # primal variables and values x = nlp._x x_opt = sol["x"] vars = nlp._vars.copy() vals = {name: subsevalf(var, x, x_opt) for name, var in vars.items()} # dual variables and values lam_g_and_h = sol["lam_g"] lam_g = lam_g_and_h[: nlp.ng, :] lam_h = lam_g_and_h[nlp.ng :, :] lam_x = sol["lam_x"] lam_lbx = -cs.fmin(lam_x[nlp.nonmasked_lbx_idx, :], 0) lam_ubx = cs.fmax(lam_x[nlp.nonmasked_ubx_idx, :], 0) dual_vars = nlp._dual_vars.copy() dual_vals = {} for n, var in dual_vars.items(): if n.startswith("lam_g"): dual_vals[n] = subsevalf(var, nlp._lam_g, lam_g) elif n.startswith("lam_h"): dual_vals[n] = subsevalf(var, nlp._lam_h, lam_h) elif n.startswith("lam_lb"): dual_vals[n] = subsevalf(var, nlp._lam_lbx, lam_lbx) elif n.startswith("lam_ub"): dual_vals[n] = subsevalf(var, nlp._lam_ubx, lam_ubx) else: raise RuntimeError(f"unknown dual variable type {n}") return EagerSolution( f, nlp._p, sol["p"], x, x_opt, cs.vertcat(nlp._lam_g, nlp._lam_h), lam_g_and_h, cs.vertcat(nlp._lam_lbx, nlp._lam_ubx), cs.vertcat(lam_lbx, lam_ubx), vars, vals, dual_vars, dual_vals, stats, nlp.unwrapped._solver_plugin, )
[docs] class LazySolution(Solution[SymType]): """Class containing information on the solution of a solver's run for an instance of :class:`csnlp.Nlp`. Parameters ---------- f : float Optimal value of the NLP at the solution. p_sym : cs.SX or MX Symbolic parameters in a vector. x_sym : SymType Symbolic primal variables in a vector. lam_g_and_h_sym : cs.SX or MX Symbolic the equality and inequality dual variables in a vector. lam_lbx_and_ubx_sym : cs.SX or MX Symbolic lower- and upper-bounds dual variables in a vector. vars : dict of (str, cs.SX or MX) Symbolic primal variables. dual_vars : dict of (str, cs.SX or MX) Symbolic dual variables. nonmasked_lbx_idx : slice or 1D array of int Indexes of the non-masked (i.e., valid, finite) lower-bounds. nonmasked_ubx_idx : slice or 1D array of int Indexes of the non-masked (i.e., valid, finite) upper-bounds. stats : dict Stats of the solver run that generated this solution. Notes ----- This class is a lazy implementation of the :class:`csnlp.solutions.Solution`, where the values and properties of the solution are computed lazily only when requested. """ def __init__( self, solution: dict[str, cs.DM], p_sym: SymType, x_sym: SymType, lam_g_and_h_sym: SymType, lam_lbx_and_ubx_sym: SymType, vars: dict[str, SymType], dual_vars: dict[str, SymType], nonmasked_lbx_idx: Union[slice, npt.NDArray[np.int64]], nonmasked_ubx_idx: Union[slice, npt.NDArray[np.int64]], stats: dict[str, _Any], solver_plugin: str, ) -> None: self._sol = solution self._p_sym = p_sym self._x_sym = x_sym self._lam_g_and_h_sym = lam_g_and_h_sym self._lam_lbx_and_ubx_sym = lam_lbx_and_ubx_sym self._vars = vars self._dual_vars = dual_vars self._nonmasked_lbx_idx = nonmasked_lbx_idx self._nonmasked_ubx_idx = nonmasked_ubx_idx self._stats = stats self._solver_plugin = solver_plugin @_cached_property def f(self) -> float: return float(self._sol["f"]) @property def p_sym(self) -> SymType: return self._p_sym @_cached_property def p(self) -> cs.DM: return self._sol["p"] @property def x_sym(self) -> SymType: return self._x_sym @_cached_property def x(self) -> cs.DM: return self._sol["x"] @property def lam_g_and_h_sym(self) -> cs.DM: return self._lam_g_and_h_sym @_cached_property def lam_g_and_h(self) -> cs.DM: return self._sol["lam_g"] @property def lam_lbx_and_ubx_sym(self) -> cs.DM: return self._lam_lbx_and_ubx_sym @_cached_property def lam_lbx_and_ubx(self) -> cs.DM: lam_x = self._sol["lam_x"] lam_lbx = -cs.fmin(lam_x[self._nonmasked_lbx_idx, :], 0) lam_ubx = cs.fmax(lam_x[self._nonmasked_ubx_idx, :], 0) return cs.vertcat(lam_lbx, lam_ubx) @_cached_property def vals(self) -> dict[str, cs.DM]: x = self._x_sym x_vals = self._sol["x"] return {n: cs.evalf(cs.substitute(v, x, x_vals)) for n, v in self._vars.items()} @_cached_property def dual_vals(self) -> dict[str, cs.DM]: lam_g_and_h_sym = self.lam_g_and_h_sym lam_g_and_h = self.lam_g_and_h lam_lbx_and_ubx_sym = self.lam_lbx_and_ubx_sym lam_lbx_and_ubx = self.lam_lbx_and_ubx dual_vals = {} for n, v in self._dual_vars.items(): if n.startswith(("lam_g", "lam_h")): dual_vals[n] = cs.evalf(cs.substitute(v, lam_g_and_h_sym, lam_g_and_h)) elif n.startswith(("lam_lb", "lam_ub")): dual_vals[n] = cs.evalf( cs.substitute(v, lam_lbx_and_ubx_sym, lam_lbx_and_ubx) ) else: raise RuntimeError(f"unknown dual variable type `{n}`") return dual_vals
[docs] @staticmethod def from_casadi_solution( sol_with_stats: dict[str, _Any], nlp: "Nlp[SymType]" ) -> "LazySolution[SymType]": stats = sol_with_stats.pop("stats") sol: dict[str, cs.DM] = sol_with_stats x = nlp._x p = nlp._p lam_g_and_h = cs.vertcat(nlp._lam_g, nlp._lam_h) lam_lbx_and_ubx = cs.vertcat(nlp._lam_lbx, nlp._lam_ubx) vars = nlp._vars.copy() dual_vars = nlp._dual_vars.copy() idx = nlp.nonmasked_lbx_idx nonmasked_lbx_idx = idx.copy() if isinstance(idx, np.ndarray) else idx idx = nlp.nonmasked_ubx_idx nonmasked_ubx_idx = idx.copy() if isinstance(idx, np.ndarray) else idx return LazySolution( sol, p, x, lam_g_and_h, lam_lbx_and_ubx, vars, dual_vars, nonmasked_lbx_idx, nonmasked_ubx_idx, stats, nlp.unwrapped._solver_plugin, )
def _broadcast_like(x: SymOrNumType, other: SymOrNumType) -> Union[SymType, np.ndarray]: """Internal utility to broadcast a value, if numerical, to the other's shape.""" if isinstance(x, (np.ndarray, cs.DM)): target_shape = other.shape if x.shape == target_shape or ( other.is_vector() and (x.size if isinstance(x, np.ndarray) else x.numel()) == other.numel() ): return x return np.broadcast_to(x, target_shape) if isinstance(x, (int, float)): return np.full(other.shape, x) return x # cs.SX or cs.MX def _internal_subsevalf_cs( expr: SymType, old: Union[SymType, dict[str, SymType], _Iterable[SymType]], new: Union[SymOrNumType, dict[str, SymOrNumType], _Iterable[SymOrNumType]], eval: bool, ) -> Union[SymType, cs.DM]: """Internal utility for substituting and evaluting casadi objects.""" if isinstance(expr, cs.DM): return expr if isinstance(old, (cs.SX, cs.MX)): new_expr = cs.substitute(expr, old, new) else: old_vals = [] new_vals = [] if isinstance(old, dict): assert isinstance(new, dict), "old and new should be of the same type" for name, old_val in old.items(): new_val = new[name] old_vals.append(old_val) new_vals.append(_broadcast_like(new_val, old_val)) else: # iterable for old_val, new_val in zip(old, new): old_vals.append(old_val) new_vals.append(_broadcast_like(new_val, old_val)) new_expr = cs.substitute(expr, cs.vvcat(old_vals), cs.vvcat(new_vals)) return cs.evalf(new_expr) if eval else new_expr def _internal_subsevalf_np( expr: np.ndarray, old: Union[SymType, dict[str, SymType], _Iterable[SymType]], new: Union[SymOrNumType, dict[str, SymOrNumType], _Iterable[SymOrNumType]], eval: bool, ) -> Union[SymType, np.ndarray, cs.DM]: """Internal utility for substituting and evaluting arrays of casadi objects.""" # if not symbolic, return it if expr.dtype != object: return expr # up to 2D, we can get away with only one substitution if expr.ndim <= 2: return _internal_subsevalf_cs(_array2cs(expr), old, new, eval) # for tensors, check if the right end or the left is bigger, then loop over the # rest while substituing in the right/left end of the array shape = expr.shape transposed = (shape[0] * shape[1]) > (shape[-2] * shape[-1]) if transposed: shape = shape[::-1] expr = expr.transpose() out = np.empty(shape, object) shape_iter, shape_cs = shape[:-2], shape[-2:] for i in _product(*map(range, shape_iter)): out[i] = _cs2array( _internal_subsevalf_cs(_array2cs(expr[i]), old, new, eval) ).reshape(shape_cs) if transposed: out = out.transpose() expr = expr.transpose() if eval: out = out.astype(float) return out
[docs] def subsevalf( expr: Union[SymType, np.ndarray], old: Union[SymType, dict[str, SymType], _Iterable[SymType]], new: Union[SymOrNumType, dict[str, SymOrNumType], _Iterable[SymOrNumType]], eval: bool = True, ) -> Union[SymType, cs.DM, np.ndarray]: """Substitutes the old variables with the new ones in the symbolic expression, and evaluates it, if required. Parameters ---------- expr : casadi.SX, MX or an array of these Expression for substitution and, possibly, evaluation. old : casadi.SX or MX or dict/iterable of these Old variable to be substituted. new : numpy.array or casadi.SX, MX, DM, int, float, or dict/iterable of these New variable that substitutes the old one. If a collection, it is assumed the type is the same of ``old`` (so, old and new should share collection type), e.g., if ``old`` is a dict, ``new`` should be a dict. eval : bool, optional Evaluates numerically the new expression. By default, ``True``. Returns ------- new_expr : casadi.SX, MX, DM or an array of these New expression after substitution (SX, MX) and, possibly, evaluation (DM). Raises ------ Exception Raises if ``old`` and ``new`` are not compatible with :func:`casadi.substitute`; or if ``eval=True`` but there are symbolic variables that are still free, i.e., the expression cannot be evaluated numerically since it is still (partially) symbolic. """ if isinstance(expr, np.ndarray): return _internal_subsevalf_np(expr, old, new, eval) return _internal_subsevalf_cs(expr, old, new, eval)