Source code for autolyap.solver_options

# SPDX-FileCopyrightText: 2025-2026 AutoLyap contributors
# SPDX-License-Identifier: GPL-3.0-only

from dataclasses import dataclass
from typing import Any, Dict, Mapping, Optional

from autolyap.utils.backend_types import CvxpyStatusModuleProtocol


SUPPORTED_SOLVER_BACKENDS = ("mosek_fusion", "cvxpy")
_DEFAULT_MOSEK_FUSION_PARAMS = {
    "intpntCoTolPfeas": 1e-8,
    "intpntCoTolDfeas": 1e-8,
    "intpntCoTolRelGap": 1e-8,
    "intpntMaxIterations": 1000,
}
_DEFAULT_CVXPY_SOLVER_PARAMS = {
    "CLARABEL": {
        "max_iter": 2000,
        "tol_feas": 1e-8,
        "tol_gap_abs": 1e-8,
        "tol_gap_rel": 1e-8,
    },
    "SCS": {
        "eps": 1e-6,
        "max_iters": 200000,
        "acceleration_lookback": 0,
    },
    "SDPA": {
        "maxIteration": 100,
        "epsilonStar": 1e-7,
        "epsilonDash": 1e-7,
    },
    "COPT": {
        "SDPMethod": 0,
        "BarIterLimit": 500,
        "FeasTol": 1e-7,
        "DualTol": 1e-7,
        "RelGap": 1e-8,
        "AbsGap": 1e-8,
        "Presolve": -1,
        "Scaling": -1,
        "Dualize": -1,
    },
}


[docs] @dataclass(frozen=True) class SolverOptions: r""" Configure solver backend selection and backend-specific options. **Used in** - ``solver_options`` argument of :meth:`~autolyap.IterationIndependent.search_lyapunov`. - ``solver_options`` argument of :meth:`~autolyap.IterationIndependent.LinearConvergence.bisection_search_rho`. - ``solver_options`` argument of :meth:`~autolyap.IterationDependent.search_lyapunov`. **Parameters** ``backend`` (``str``, default ``"mosek_fusion"``) Selects the modeling/solver backend. ``"mosek_fusion"`` builds and solves directly with MOSEK Fusion. ``"cvxpy"`` builds the problem in CVXPY and solves it with ``cvxpy_solver``. ``mosek_params`` (``Mapping[str, Any] | None``, default ``None``) Extra MOSEK Fusion solver options as key-value pairs. Used only when ``backend="mosek_fusion"``. If ``None``, AutoLyap applies the explicit default MOSEK profile: ``intpntCoTolPfeas=1e-8``, ``intpntCoTolDfeas=1e-8``, ``intpntCoTolRelGap=1e-8``, and ``intpntMaxIterations=1000``. ``cvxpy_solver`` (``str | None``, default ``None``) Name of the CVXPY solver, for example ``"CLARABEL"``, ``"SCS"``, ``"MOSEK"``, ``"SDPA"``, or ``"COPT"``. If ``None``, CVXPY chooses the solver. Used only when ``backend="cvxpy"``. ``cvxpy_solver_params`` (``Mapping[str, Any] | None``, default ``None``) Extra solver options for the selected CVXPY solver. See the examples below for recommended profiles and common options. AutoLyap applies ``warm_start=True`` by default. Used only when ``backend="cvxpy"``. ``cvxpy_accept_inaccurate`` (``bool``, default ``True``) Controls which CVXPY statuses are accepted as successful solves. If ``True``, accept both ``OPTIMAL`` and ``OPTIMAL_INACCURATE``. If ``False``, require ``OPTIMAL``. Used only when ``backend="cvxpy"``. **Examples** .. code-block:: python # MOSEK Fusion (explicit default profile; requires `pip install mosek`) SolverOptions( backend="mosek_fusion", mosek_params={ "intpntCoTolPfeas": 1e-8, # default "intpntCoTolDfeas": 1e-8, # default "intpntCoTolRelGap": 1e-8, # default "intpntMaxIterations": 1000, # default }, ) # CVXPY + CLARABEL (explicit default profile) SolverOptions( backend="cvxpy", cvxpy_solver="CLARABEL", cvxpy_accept_inaccurate=True, # default cvxpy_solver_params={ "max_iter": 2000, # default "tol_feas": 1e-8, # default "tol_gap_abs": 1e-8, # default "tol_gap_rel": 1e-8, # default "warm_start": True, # default }, ) # CVXPY + MOSEK (explicit default profile; requires `pip install mosek`) SolverOptions( backend="cvxpy", cvxpy_solver="MOSEK", cvxpy_accept_inaccurate=True, # default cvxpy_solver_params={ "MSK_DPAR_INTPNT_CO_TOL_PFEAS": 1e-8, # default "MSK_DPAR_INTPNT_CO_TOL_DFEAS": 1e-8, # default "MSK_DPAR_INTPNT_CO_TOL_REL_GAP": 1e-8, # default "warm_start": True, # default }, ) # CVXPY + SDPA (explicit default profile; requires `pip install sdpa-python`) SolverOptions( backend="cvxpy", cvxpy_solver="SDPA", cvxpy_accept_inaccurate=True, # default cvxpy_solver_params={ "maxIteration": 100, # default "epsilonStar": 1e-7, # default "epsilonDash": 1e-7, # default "warm_start": True, # default }, ) # CVXPY + SDPA multiprecision (high-precision profile; requires `pip install sdpa-multiprecision`) SolverOptions( backend="cvxpy", cvxpy_solver="SDPA", cvxpy_accept_inaccurate=True, # default cvxpy_solver_params={ "maxIteration": 500, "epsilonStar": 1e-30, "epsilonDash": 1e-30, "mpfPrecision": 512, "warm_start": True, # default }, ) # CVXPY + SCS (explicit default profile) SolverOptions( backend="cvxpy", cvxpy_solver="SCS", cvxpy_accept_inaccurate=True, # default cvxpy_solver_params={ "eps": 1e-6, # default "max_iters": 200000, # default "acceleration_lookback": 0, # default "warm_start": True, # default }, ) # CVXPY + COPT (explicit default profile; requires `pip install coptpy`) SolverOptions( backend="cvxpy", cvxpy_solver="COPT", cvxpy_accept_inaccurate=True, # default cvxpy_solver_params={ "SDPMethod": 0, # default "BarIterLimit": 500, # default "FeasTol": 1e-7, # default "DualTol": 1e-7, # default "RelGap": 1e-8, # default "AbsGap": 1e-8, # default "Presolve": -1, # default "Scaling": -1, # default "Dualize": -1, # default "warm_start": True, # default }, ) """ backend: str = "mosek_fusion" mosek_params: Optional[Mapping[str, Any]] = None cvxpy_solver: Optional[str] = None cvxpy_solver_params: Optional[Mapping[str, Any]] = None cvxpy_accept_inaccurate: bool = True
[docs] def _normalize_solver_options(options: Optional[SolverOptions]) -> SolverOptions: r""" Validate and normalize user-provided solver options. If `options` is `None`, returns the default ``SolverOptions()`` profile. Otherwise, this routine checks backend names, normalizes mapping fields, and returns a sanitized immutable `SolverOptions` instance. """ if options is None: return SolverOptions() if not isinstance(options, SolverOptions): raise ValueError( "solver_options must be an instance of autolyap.solver_options.SolverOptions or None." ) backend = str(options.backend).strip().lower() if backend not in SUPPORTED_SOLVER_BACKENDS: raise ValueError( f"Unsupported solver backend '{options.backend}'. " f"Expected one of {SUPPORTED_SOLVER_BACKENDS}." ) mosek_params = _normalize_named_mapping(options.mosek_params, "mosek_params") cvxpy_solver = None if options.cvxpy_solver is not None: cvxpy_solver = str(options.cvxpy_solver).strip() if not cvxpy_solver: raise ValueError("cvxpy_solver cannot be an empty string.") cvxpy_solver_params = _normalize_cvxpy_solver_params( options.cvxpy_solver, options.cvxpy_solver_params, ) cvxpy_accept_inaccurate = options.cvxpy_accept_inaccurate if not isinstance(cvxpy_accept_inaccurate, bool): raise ValueError("cvxpy_accept_inaccurate must be a bool.") return SolverOptions( backend=backend, mosek_params=mosek_params, cvxpy_solver=cvxpy_solver, cvxpy_solver_params=cvxpy_solver_params, cvxpy_accept_inaccurate=cvxpy_accept_inaccurate, )
[docs] def _get_cvxpy_solve_kwargs(options: SolverOptions) -> Dict[str, Any]: r""" Build keyword arguments for ``cvxpy.Problem.solve(...)``. Merges: 1. Selected solver name (`solver`). 2. Built-in defaults for known solvers. 3. User overrides from ``cvxpy_solver_params``. Always defaults ``warm_start=True`` unless explicitly overridden. """ kwargs: Dict[str, Any] = {} if options.cvxpy_solver is not None: solver_name = options.cvxpy_solver kwargs["solver"] = solver_name kwargs.update( dict(_DEFAULT_CVXPY_SOLVER_PARAMS.get(solver_name.upper(), {})) ) if options.cvxpy_solver_params is not None: user_kwargs = dict(options.cvxpy_solver_params) if options.cvxpy_solver is not None and options.cvxpy_solver.upper() == "COPT": nested_copt_params = user_kwargs.get("params") if isinstance(nested_copt_params, Mapping): user_kwargs.pop("params") flattened_copt_params = dict(nested_copt_params) flattened_copt_params.update(user_kwargs) user_kwargs = flattened_copt_params default_params = kwargs.get("params") user_params = user_kwargs.get("params") if isinstance(default_params, Mapping) and isinstance(user_params, Mapping): merged_params = dict(default_params) merged_params.update(dict(user_params)) user_kwargs["params"] = merged_params kwargs.update(user_kwargs) kwargs.setdefault("warm_start", True) return kwargs
[docs] def _get_cvxpy_accepted_statuses(cp: CvxpyStatusModuleProtocol, options: SolverOptions) -> set[str]: r""" Return CVXPY statuses treated as successful solves. Always includes ``OPTIMAL``; optionally includes ``OPTIMAL_INACCURATE`` when enabled by solver options. """ statuses = {cp.OPTIMAL} if options.cvxpy_accept_inaccurate: statuses.add(cp.OPTIMAL_INACCURATE) return statuses
[docs] def _normalize_mapping(mapping: Optional[Mapping[str, Any]], name: str) -> Optional[Dict[str, Any]]: r"""Validate an optional mapping and return it as a plain dict copy.""" if mapping is None: return None if not isinstance(mapping, Mapping): raise ValueError(f"{name} must be a mapping/dict when provided.") return dict(mapping)
def _normalize_cvxpy_solver_params( cvxpy_solver: Optional[str], cvxpy_solver_params: Optional[Mapping[str, Any]], ) -> Optional[Dict[str, Any]]: r""" Validate and normalize CVXPY solver kwargs. For ``cvxpy_solver="COPT"``, AutoLyap accepts either a flat parameter map (preferred) or ``params={...}`` and flattens the nested mapping. For ``cvxpy_solver="MOSEK"``, AutoLyap accepts MOSEK parameters either under ``mosek_params={...}`` or as top-level ``MSK_*`` keys. """ normalized = _normalize_mapping(cvxpy_solver_params, "cvxpy_solver_params") if normalized is None: return None solver_name = cvxpy_solver.upper() if cvxpy_solver is not None else None if solver_name == "COPT": nested_copt_params = normalized.get("params") if nested_copt_params is not None: if not isinstance(nested_copt_params, Mapping): raise ValueError( "For cvxpy_solver='COPT', cvxpy_solver_params['params'] must be a mapping." ) normalized.pop("params") flattened_copt_params = dict(nested_copt_params) flattened_copt_params.update(normalized) normalized = flattened_copt_params if solver_name == "MOSEK": nested_mosek_params = normalized.get("mosek_params") if nested_mosek_params is not None and not isinstance(nested_mosek_params, Mapping): raise ValueError( "For cvxpy_solver='MOSEK', cvxpy_solver_params['mosek_params'] must be a mapping." ) flat_mosek_params = { key: value for key, value in normalized.items() if _is_mosek_param_key(key) } for key in flat_mosek_params: normalized.pop(key) if nested_mosek_params is not None or flat_mosek_params: merged_mosek_params: Dict[str, Any] = {} if isinstance(nested_mosek_params, Mapping): merged_mosek_params.update(dict(nested_mosek_params)) merged_mosek_params.update(flat_mosek_params) normalized["mosek_params"] = merged_mosek_params return normalized def _is_mosek_param_key(key: Any) -> bool: r"""Return True when a key looks like a MOSEK string parameter name.""" return isinstance(key, str) and key.strip().upper().startswith("MSK_")
[docs] def _normalize_named_mapping(mapping: Optional[Mapping[str, Any]], name: str) -> Optional[Dict[str, Any]]: r""" Validate an optional mapping with string keys and return a dict copy. Keys must be non-empty strings after stripping whitespace. """ if mapping is None: return None if not isinstance(mapping, Mapping): raise ValueError(f"{name} must be a mapping/dict when provided.") normalized: Dict[str, Any] = {} for key, value in mapping.items(): if not isinstance(key, str) or not key.strip(): raise ValueError(f"All keys in {name} must be non-empty strings.") normalized[key] = value return normalized