Source code for pympcc.solver

"""Top-level solver interface."""
from __future__ import annotations

import logging
import warnings
from typing import Callable, Optional, Union

import numpy as np

from ._autoscale import autoscale_comp_pairs as _autoscale_comp_pairs
from ._constants import BIACTIVE_TOL_FLOOR
from ._diagnostics import classify_cq as _classify_cq
from ._diagnostics import degeneracy_report as _degeneracy_report
from ._diagnostics import initial_point_statistics as _initial_point_stats
from ._diagnostics import jac_condition_number as _jac_cond
from ._diagnostics import jac_norms as _jac_norms
from ._diagnostics import merit_cross_check as _merit_cross_check
from ._kernels import HAS_NUMBA
from ._presolve import presolve as _presolve
from ._sosc import sosc_check as _sosc_check
from ._stationarity import verify_b_stationarity as _verify_b_stat
from .models import StructuredMPCC
from .problem import MPCCProblem
from .result import IPOPTStatus, IterationInfo, MPCCResult
from .strategies.augmented_lagrangian import AugmentedLagrangianStrategy
from .strategies.direct import DirectStrategy
from .strategies.lin_fukushima import LinFukushimaStrategy
from .strategies.ncp import (
    BillupsStrategy,
    ChenChenKanzowStrategy,
    ChenMangasarianStrategy,
    KanzowSchwartzStrategy,
    SmoothMinStrategy,
    VeelkenUlbrichPowStrategy,
    VeelkenUlbrichSinStrategy,
)
from .strategies.ncp_reformulation import NCPReformulationStrategy
from .strategies.scholtes import ScholtesStrategy
from .strategies.slack import SlackStrategy
from .strategies.smoothing import SmoothingStrategy

__all__ = ["MPCCSolver", "solve"]

from ._typing import BackendName, StrategyName

ProblemLike = Union[MPCCProblem, StructuredMPCC]


def _as_mpcc_problem(problem: ProblemLike) -> MPCCProblem:
    """Convert *problem* to :class:`MPCCProblem` if needed."""
    if isinstance(problem, StructuredMPCC):
        return problem.to_mpcc_problem()
    return problem


def _problem_signature(problem: MPCCProblem) -> tuple:
    """Hashable fingerprint of an MPCCProblem's structural shape.

    Two problems share a signature when every NLP-shaping dimension and
    every Jacobian sparsity pattern matches.  Used by
    :meth:`MPCCSolver.resolve` to detect whether the warm-start state from
    the previous solve is reusable: equal signatures → safe warm-start;
    different signatures → cold-fallback (rebuild strategy).

    The fingerprint uses ``len(rows)`` plus the integer sums of ``rows``
    and ``cols`` for each declared sparsity pattern.  That's stable across
    permutations of identical patterns (``np.lexsort`` may have reordered
    one but not the other) and cheap enough that ``resolve`` calls don't
    pay an O(nnz) hash on every invocation.
    """
    def _sp_sig(sp):
        if sp is None:
            return ("dense",)
        rows, cols = sp
        rows_a = np.asarray(rows)
        cols_a = np.asarray(cols)
        return ("sparse", int(rows_a.size),
                int(rows_a.sum()) if rows_a.size else 0,
                int(cols_a.sum()) if cols_a.size else 0)
    return (
        int(problem.n),
        int(problem.n_comp),
        int(problem.n_eq),
        int(problem.n_ineq),
        _sp_sig(problem.comp_G_jacobian_sparsity),
        _sp_sig(problem.comp_H_jacobian_sparsity),
        _sp_sig(problem.eq_jacobian_sparsity),
        _sp_sig(problem.ineq_jacobian_sparsity),
    )

_STRATEGIES = {
    "direct": DirectStrategy,
    "scholtes": ScholtesStrategy,
    "smoothing": SmoothingStrategy,
    "lin_fukushima": LinFukushimaStrategy,
    "augmented_lagrangian": AugmentedLagrangianStrategy,
    "slack": SlackStrategy,
    "smooth_min": SmoothMinStrategy,
    "chen_chen_kanzow": ChenChenKanzowStrategy,
    "kanzow_schwartz": KanzowSchwartzStrategy,
    "chen_mangasarian": ChenMangasarianStrategy,
    "billups": BillupsStrategy,
    "veelken_ulbrich_pow": VeelkenUlbrichPowStrategy,
    "veelken_ulbrich_sin": VeelkenUlbrichSinStrategy,
    "ncp": NCPReformulationStrategy,
}

_log = logging.getLogger("pympcc")
_log.addHandler(logging.NullHandler())


def _print_verbose_preamble(problem: MPCCProblem, strategy_name: str,
                            backend: str = "ipopt") -> None:
    """One-line banner printed to stdout before the iteration table when ``verbose=True``."""
    kernel_str = (
        "Numba JIT" if HAS_NUMBA
        else "NumPy (pip install numba for faster runs)"
    )
    parts = [f"strategy={strategy_name}", f"backend={backend}",
             f"n={problem.n}", f"n_comp={problem.n_comp}"]
    if problem.n_ineq:
        parts.append(f"n_ineq={problem.n_ineq}")
    if problem.n_eq:
        parts.append(f"n_eq={problem.n_eq}")
    parts.append(f"kernel={kernel_str}")
    print("pympcc  " + "  ".join(parts))


def _default_verbose_callback(k: int, info: IterationInfo) -> None:
    """Built-in per-iteration printer used when ``verbose=True``."""
    try:
        status_str = IPOPTStatus(info.status).name
    except ValueError:
        status_str = str(info.status)
    if k == 0:
        print(f"{'iter':>4}  {'epsilon':>12}  {'obj':>14}  {'comp_max':>12}  {'comp_mean':>12}  {'kkt_res':>10}  {'ipopt_it':>8}  {'status':<13}  {'time(s)':>9}")
        print("-" * 110)
    kkt_str = f"{info.kkt_residual:.3e}" if info.kkt_residual is not None else "n/a"
    print(f"{k+1:>4}  {info.epsilon:>12.4e}  {info.obj:>14.6g}  "
          f"{info.comp_residual:>12.3e}  {info.comp_residual_mean:>12.3e}  "
          f"{kkt_str:>10}  {info.n_ipopt_iter:>8d}  {status_str:<13}  {info.iter_time:>9.3f}")

# IPOPT options applied by default (users can override via ipopt_options)
_DEFAULT_IPOPT_OPTIONS: dict = {
    "print_level": 0,   # suppress per-iteration output
    "sb": "yes",        # suppress startup banner
}


[docs] class MPCCSolver: """ Solver for Mathematical Programs with Complementarity Constraints (MPCC). Parameters ---------- problem : MPCCProblem The MPCC problem instance. strategy : {'direct', 'scholtes', 'smoothing', 'lin_fukushima', 'augmented_lagrangian', 'slack'} Reformulation strategy (default ``'scholtes'``). - ``'direct'`` — single NLP solve with ``G·H ≤ 0``. - ``'scholtes'`` — outer loop relaxing ``G·H ≤ ε`` with ``ε → 0``. - ``'smoothing'`` — Fischer-Burmeister smoothing ``φ_ε(G,H) = 0``. - ``'lin_fukushima'`` — Scholtes + ``G+H ≥ ε`` to preserve MPCC-MFCQ. - ``'augmented_lagrangian'`` — PHR penalty; complementarity in objective only. - ``'slack'`` — lifting strategy with slack variables ``s_G = G(x)``, ``s_H = H(x)``; the complementarity Jacobian rows have zero x-block, which is efficient when ``n_comp ≪ n``. Incompatible with ``backend='filterSQP'``. backend : {'ipopt', 'filterSQP', 'scipy'}, optional NLP backend solver (default ``'ipopt'``). - ``'ipopt'`` — uses IPOPT via cyipopt (default, fully supported). - ``'filterSQP'`` — uses pyfiltersqp (L-BFGS SQP). Requires the ``pyfiltersqp`` package to be installed. The ``'slack'`` strategy is incompatible with this backend. - ``'scipy'`` — uses ``scipy.optimize.minimize`` with ``method='trust-constr'``. Requires ``scipy>=1.10``. No IPOPT installation required; suitable for small problems and IPOPT-free environments. Dual warm-starting is accepted but not forwarded (scipy does not support multiplier warm-starts); exact Hessians are not used. ipopt_options : dict, optional Options passed to the IPOPT backend (e.g. ``{"max_iter": 500, "tol": 1e-8}``). When ``backend='filterSQP'`` the common keys ``"tol"`` and ``"max_iter"`` are translated to filterSQP equivalents; IPOPT-specific keys are silently ignored. Merged with package defaults; user values take precedence. solver_options : dict, optional Options forwarded directly to :class:`~pyfiltersqp.SQPSolver` (e.g. ``{"tol_feas": 1e-7, "lbfgs_memory": 20}``). Ignored when ``backend='ipopt'``. **strategy_options Extra keyword arguments forwarded to the strategy class (e.g. ``epsilon_0``, ``reduction`` for Scholtes / smoothing). Pass ``dual_warmstart=False`` to disable dual warm-starting between outer iterations (default ``True`` for all iterative strategies). callback : callable, optional ``f(k: int, info: IterationInfo) -> None`` called after each outer iteration, where ``k`` is the 0-based iteration index. Not called by the ``'direct'`` strategy (single solve, no outer loop). inner_callback : callable, optional ``f(iter_count: int, info: dict) -> bool`` invoked once per IPOPT inner iteration. Return ``False`` to stop the inner solve early. ``info`` mirrors IPOPT's ``intermediate`` arguments. Ignored by the ``'filterSQP'`` and ``'scipy'`` backends. time_limit : float, optional Wall-clock budget in seconds for the outer iterative loop. When the budget is exhausted the loop terminates and the best feasible incumbent (smallest ``comp_residual`` among accepted iterates) is returned with ``result.time_limit_hit = True``. Does not interrupt an in-flight inner NLP solve — use IPOPT's ``max_cpu_secs`` for that. Ignored by the ``'direct'`` strategy. verbose : bool, optional If ``True`` and no *callback* is provided, prints a formatted progress table to stdout after each outer iteration (default ``False``). Examples -------- >>> solver = MPCCSolver(problem, strategy='scholtes', ... epsilon_0=0.5, reduction=0.1) >>> result = solver.solve() Using the filterSQP backend:: result = pympcc.solve(problem, backend='filterSQP', solver_options={'lbfgs_memory': 20}) """
[docs] def __init__( self, problem: ProblemLike, strategy: StrategyName = "scholtes", backend: BackendName = "ipopt", ipopt_options: dict | None = None, solver_options: dict | None = None, callback: Optional[Callable[[int, IterationInfo], None]] = None, inner_callback: Optional[Callable[[int, dict], bool]] = None, time_limit: Optional[float] = None, verbose: bool = False, presolve: bool = False, diagnostics: bool = False, autoscale: bool = False, b_stat_max_biactive: int = 10, tnlp_refine: bool = False, tnlp_max_iter: int = 500, **strategy_options, ) -> None: if strategy not in _STRATEGIES: raise ValueError( f"Unknown strategy {strategy!r}. " f"Available: {sorted(_STRATEGIES)}" ) if backend not in ("ipopt", "filterSQP", "scipy"): raise ValueError( f"Unknown backend {backend!r}. " f"Choose 'ipopt', 'filterSQP', or 'scipy'." ) # Pop linear_solver_fn before the _VALID_OPTIONS check: it's a solver-level # option, not a strategy option, and it bypasses IPOPT's built-in linear solver. linear_solver_fn = strategy_options.pop("linear_solver_fn", None) strategy_cls = _STRATEGIES[strategy] valid_opts: frozenset = getattr(strategy_cls, "_VALID_OPTIONS", frozenset()) unknown = set(strategy_options) - valid_opts if unknown: raise TypeError( f"Unknown option(s) for strategy {strategy!r}: " f"{sorted(unknown)}. " f"Valid options: {sorted(valid_opts) if valid_opts else '(none)'}" ) self.problem_orig = _as_mpcc_problem(problem) if presolve: self.problem, self._presolve_map = _presolve(self.problem_orig) else: self.problem, self._presolve_map = self.problem_orig, None # type: ignore[assignment] if autoscale: self._apply_autoscale() self.strategy_name = strategy self.backend = backend self.ipopt_options = {**_DEFAULT_IPOPT_OPTIONS, **(ipopt_options or {})} self.solver_options = solver_options or {} self.strategy_options = strategy_options self._verbose = verbose and callback is None if self._verbose: callback = _default_verbose_callback self._strategy = _STRATEGIES[strategy]( self.problem, self.ipopt_options, backend=backend, solver_options=self.solver_options, callback=callback, inner_callback=inner_callback, time_limit=time_limit, **strategy_options, ) # linear_solver_fn bypasses the strategy's _VALID_OPTIONS and is injected # directly so individual strategies don't need to forward it. if linear_solver_fn is not None: self._strategy._linear_solver_fn = linear_solver_fn self._diagnostics = diagnostics self._b_stat_max_biactive = b_stat_max_biactive self._tnlp_refine = tnlp_refine self._tnlp_max_iter = tnlp_max_iter # Hot-start retention (§6.5). Populated by :meth:`solve` so that # :meth:`resolve` can warm-start subsequent solves with the same # structure. ``_signature`` lets resolve detect cold-fallback; # ``_cold_baseline_iter`` is the IPOPT iter total of the very first # solve and is used to compute ``warmstart_savings_iter``. self._signature: tuple = _problem_signature(self.problem) self._last_result: Optional[MPCCResult] = None self._cold_baseline_iter: Optional[int] = None self._presolve_on: bool = bool(presolve) self._autoscale_on: bool = bool(autoscale) self._strategy_cls = _STRATEGIES[strategy] self._linear_solver_fn = linear_solver_fn
[docs] def solve(self) -> MPCCResult: """Run the solver and return an :class:`MPCCResult`.""" if self._verbose: _print_verbose_preamble(self.problem, self.strategy_name, self.backend) result = self._strategy.solve() result.time_limit_hit = bool(getattr(self._strategy, "_time_limit_hit", False)) # Propagate complementarity-pair scaling (if any) to the result so # downstream callers can recover unscaled multipliers. Done here in # one place rather than in every strategy. if self.problem.comp_G_scale is not None: result.comp_G_scale = self.problem.comp_G_scale if self.problem.comp_H_scale is not None: result.comp_H_scale = self.problem.comp_H_scale if self._presolve_map is not None and not self._presolve_map.is_identity: result = self._presolve_map.expand_result(result, self.problem_orig) if self._diagnostics: self._attach_diagnostics(result) # Always populate per_pair_status from G, H (O(n_comp), no cost). self._attach_per_pair_status(result) if self._tnlp_refine: self._attach_tnlp_refinement(result) # §6.5 hot-start telemetry & state retention. self._populate_warmstart_fields(result) self._last_result = result return result
def _populate_warmstart_fields(self, result: MPCCResult) -> None: """Aggregate IPOPT iter counts and seed cold-baseline / savings. Direct strategy has no ``history``; for that case the strategy's last ``_timed_solve`` left ``_last_solve_state`` populated and the NLP object's ``n_ipopt_iter`` is the total. Iterative strategies report per-outer-iteration counts in ``history`` which we sum. """ if result.history: n_iter = sum(int(it.n_ipopt_iter) for it in result.history) else: # Direct path — read from the strategy's last NLP object. The # strategy stashed ``_last_solve_state`` on the BaseStrategy # in ``_timed_solve`` but the iter count lives on the cyipopt # adapter; pull it from a side-channel attribute we add below. n_iter = int(getattr(self._strategy, "_last_n_ipopt_iter", 0) or 0) result.n_ipopt_iter_total = n_iter if self._cold_baseline_iter is None: self._cold_baseline_iter = n_iter else: result.warmstart_savings_iter = self._cold_baseline_iter - n_iter
[docs] def resolve( self, problem: ProblemLike, *, warm_x0: bool = True, warm_dual: bool = True, ) -> MPCCResult: """Re-solve a near-identical MPCC reusing the previous result's state. Parameters ---------- problem : MPCCProblem or StructuredMPCC The new problem. Must share the structural signature (``n``, ``n_comp``, ``n_eq``, ``n_ineq``, every Jacobian sparsity pattern) of the problem this solver was constructed with; numeric values may differ freely. warm_x0 : bool When ``True`` (default), seed the new ``problem.x0`` with the previous result's ``x*`` (clipped onto the new variable bounds). When ``False``, use whatever ``x0`` the new problem carries. warm_dual : bool When ``True`` (default), forward the previous solve's ``mult_g`` / ``mult_x_L`` / ``mult_x_U`` to IPOPT and toggle ``warm_start_init_point=yes`` on the first inner solve. Returns ------- MPCCResult ``result.warmstart_savings_iter`` carries the IPOPT iter delta vs the cold-baseline solve. Notes ----- - When the structural signature changes, this method emits a ``UserWarning`` and falls back to a cold-rebuild of the strategy. The cold-rebuild's iter count becomes the new baseline. - ``autoscale`` is intentionally not re-applied on resolve: rescaling comp pairs would invalidate the warm dual. Reconstruct the solver if you need a fresh autoscale probe. - Subsequent ``solve()`` calls on the same instance also act as warm-restarts (the warm state persists); ``resolve`` is the intended entry point because it lets you swap the problem in. """ if self._last_result is None: raise RuntimeError( "MPCCSolver.resolve() requires a prior solve(); " "call solve() first to establish the warm-start baseline." ) new_orig = _as_mpcc_problem(problem) new_sig = _problem_signature(new_orig) if new_sig != self._signature: warnings.warn( "MPCCSolver.resolve(): problem structure changed " f"(was {self._signature}, now {new_sig}); " "rebuilding strategy from scratch (cold start).", UserWarning, stacklevel=2, ) return self._cold_restart(new_orig) # Swap problem references in place; presolve and autoscale are # re-applied only when they were originally on, so warm semantics # match cold semantics for the same solver configuration. self.problem_orig = new_orig if self._presolve_on: self.problem, self._presolve_map = _presolve(self.problem_orig) else: self.problem = self.problem_orig self._presolve_map = None # type: ignore[assignment] # Strategy holds its own reference to the problem; refresh it so # callbacks see the new numeric values. self._strategy.problem = self.problem if warm_x0: x_seed = np.clip( np.asarray(self._last_result.x, dtype=float), self.problem.xl, self.problem.xu, ) self.problem.x0 = x_seed if warm_dual: seed = self._strategy._last_solve_state or {} cleaned = {k: v for k, v in seed.items() if v is not None} if cleaned: self._strategy._initial_warm_dual = cleaned return self.solve()
def _cold_restart(self, new_orig: MPCCProblem) -> MPCCResult: """Tear down the strategy, reset baselines, rebuild from *new_orig*.""" self.problem_orig = new_orig if self._presolve_on: self.problem, self._presolve_map = _presolve(self.problem_orig) else: self.problem = self.problem_orig self._presolve_map = None # type: ignore[assignment] if self._autoscale_on: self._apply_autoscale() self._signature = _problem_signature(self.problem) self._cold_baseline_iter = None self._last_result = None callback = self._strategy.callback inner_callback = self._strategy.inner_callback time_limit = self._strategy.time_limit self._strategy = self._strategy_cls( self.problem, self.ipopt_options, backend=self.backend, solver_options=self.solver_options, callback=callback, inner_callback=inner_callback, time_limit=time_limit, **self.strategy_options, ) if self._linear_solver_fn is not None: self._strategy._linear_solver_fn = self._linear_solver_fn return self.solve() def _apply_autoscale(self) -> None: """Populate ``problem.comp_G_scale`` / ``comp_H_scale`` from a probe. Skips silently when either scale is already user-supplied so manual scaling always wins. When autoscale rescales at least one pair, emits a one-line ``UserWarning`` reporting the count. """ import warnings as _warnings if (self.problem.comp_G_scale is not None or self.problem.comp_H_scale is not None): return s_G, s_H = _autoscale_comp_pairs(self.problem) n_rescaled = int(np.sum((s_G != 1.0) | (s_H != 1.0))) if n_rescaled == 0: return self.problem.comp_G_scale = s_G self.problem.comp_H_scale = s_H _warnings.warn( f"pympcc autoscale: rescaled {n_rescaled}/{self.problem.n_comp} " "complementarity pair(s) to balance |G_i| / |H_i|.", UserWarning, stacklevel=3, ) def _attach_diagnostics(self, result: MPCCResult) -> None: """Run §2.1 / §2.2 / §2.3 diagnostics on the original-space result.""" cq = _classify_cq(result, self.problem_orig) result.cq = cq["cq"] result.cq_active_set_sizes = cq["active_set_sizes"] result.cq_rank_deficit = cq["rank_deficit"] bs = _verify_b_stat(result, self.problem_orig, max_biactive=self._b_stat_max_biactive) result.b_stationary = bs["status"] result.b_stationary_witness = bs["witness_branch"] result.b_stationary_min_descent = bs["min_descent"] sc = _sosc_check(result, self.problem_orig) result.sosc = sc["sosc"] result.sosc_min_eigenvalue = sc["min_eigenvalue"] result.sosc_skipped_reason = sc["skipped_reason"] result.hessian_condition_estimate = sc.get("cond_W") result.jac_condition = _jac_cond(result, self.problem_orig) # §2.7 — PATH-style multi-merit & degeneracy diagnostics. result.merit_cross_check = _merit_cross_check(result, self.problem_orig) jn = _jac_norms(result, self.problem_orig) result.jac_row_norms = jn["row"] result.jac_col_norms = jn["col"] result.degeneracy_report = _degeneracy_report(result, self.problem_orig) result.initial_point_stats = _initial_point_stats(self.problem_orig) @staticmethod def _attach_per_pair_status(result: MPCCResult) -> None: """Populate ``result.per_pair_status`` from G, H values (§4.6). Uses an adaptive threshold: ``max(sqrt(comp_residual), 1e-6)`` matching the biactive-detection heuristic in ``_infer_active_set``. A flat 1e-6 cutoff misclassifies near-biactive points where both G and H sit at O(sqrt(ε)) due to the relaxation barrier. """ G = np.asarray(result.G) H = np.asarray(result.H) comp = float(np.max(np.abs(G * H))) if G.size else 0.0 # Slight upward slack (1 ppm) so that G≈H≈sqrt(comp) cases aren't # rejected by a ULP-level floating-point boundary. tol = max(np.sqrt(comp) * (1.0 + BIACTIVE_TOL_FLOOR), BIACTIVE_TOL_FLOOR) status = [] for g, h in zip(G, H): if g <= tol and h <= tol: status.append("biactive") elif g <= tol: status.append("G_active") elif h <= tol: status.append("H_active") else: status.append("inactive") result.per_pair_status = status def _attach_tnlp_refinement(self, result: MPCCResult) -> None: """Run §2.6 TNLP active-set refinement and populate ``result.tnlp_refined``.""" if not result.success: return from ._tnlp import run_tnlp_refinement as _tnlp tnlp = _tnlp( result, self.problem_orig, self._strategy, max_iter=self._tnlp_max_iter, ) result.tnlp_refined = tnlp if tnlp.success: result.mult_comp_G_mpcc = tnlp.mult_comp_G result.mult_comp_H_mpcc = tnlp.mult_comp_H
[docs] def solve( problem: ProblemLike, strategy: StrategyName = "scholtes", backend: BackendName = "ipopt", ipopt_options: dict | None = None, solver_options: dict | None = None, callback: Optional[Callable[[int, IterationInfo], None]] = None, inner_callback: Optional[Callable[[int, dict], bool]] = None, time_limit: Optional[float] = None, verbose: bool = False, presolve: bool = False, diagnostics: bool = False, autoscale: bool = False, b_stat_max_biactive: int = 10, tnlp_refine: bool = False, tnlp_max_iter: int = 500, n_starts: int = 1, perturb_scale: float = 0.1, multistart_seed: int = 0, n_jobs: int = 1, **strategy_options, ) -> MPCCResult: """ Solve an MPCC problem — convenience wrapper around :class:`MPCCSolver`. Parameters ---------- problem : MPCCProblem The MPCC problem instance. strategy : {'direct', 'scholtes', 'smoothing', 'lin_fukushima', 'augmented_lagrangian', 'slack'} Reformulation strategy (default ``'scholtes'``). See :class:`MPCCSolver` for a description of each option. backend : {'ipopt', 'filterSQP', 'scipy'}, optional NLP backend solver (default ``'ipopt'``). ``'filterSQP'`` requires the ``pyfiltersqp`` package and is incompatible with ``strategy='slack'``. ``'scipy'`` uses ``scipy.optimize.minimize`` with ``method='trust-constr'`` and requires no IPOPT installation. ipopt_options : dict, optional IPOPT solver options (merged with package defaults). When ``backend='filterSQP'`` the common keys ``"tol"`` and ``"max_iter"`` are translated; IPOPT-specific keys are silently ignored. solver_options : dict, optional Options forwarded directly to :class:`~pyfiltersqp.SQPSolver`. Ignored when ``backend='ipopt'``. callback : callable, optional ``f(k: int, info: IterationInfo) -> None`` called after each outer iteration. Not called by the ``'direct'`` strategy. inner_callback : callable, optional ``f(iter_count: int, info: dict) -> bool`` invoked once per IPOPT inner iteration (every NLP solve, including each outer-loop NLP). ``info`` carries IPOPT's ``intermediate`` arguments (``alg_mod``, ``obj_value``, ``inf_pr``, ``inf_du``, ``mu``, ``d_norm``, ``regularization_size``, ``alpha_du``, ``alpha_pr``, ``ls_trials``). Return ``False`` to stop the current inner solve early; ``True`` (or ``None``) to continue. verbose : bool, optional If ``True`` and no *callback* is provided, prints a formatted progress table to stdout after each outer iteration (default ``False``). **strategy_options Additional options for the strategy (e.g. ``epsilon_0``, ``reduction``, ``max_iter``). Returns ------- MPCCResult Examples -------- Minimal call:: result = pympcc.solve(problem) Use the filterSQP backend:: result = pympcc.solve(problem, backend='filterSQP') Print progress after each outer iteration:: result = pympcc.solve(problem, strategy='scholtes', verbose=True) Custom callback:: def my_cb(k, info): print(f"[{k}] obj={info.obj:.6f} comp={info.comp_residual:.2e}") result = pympcc.solve(problem, strategy='smoothing', callback=my_cb) Enable verbose IPOPT output:: result = pympcc.solve(problem, ipopt_options={'print_level': 5}) """ if n_starts > 1: from .multistart import multistart as _multistart return _multistart( # type: ignore[return-value] problem, n_starts=n_starts, perturb_scale=perturb_scale, seed=multistart_seed, n_jobs=n_jobs, strategy=strategy, backend=backend, ipopt_options=ipopt_options, solver_options=solver_options, callback=callback, verbose=verbose, presolve=presolve, diagnostics=diagnostics, autoscale=autoscale, b_stat_max_biactive=b_stat_max_biactive, tnlp_refine=tnlp_refine, tnlp_max_iter=tnlp_max_iter, **strategy_options, ) return MPCCSolver( _as_mpcc_problem(problem), strategy, backend=backend, ipopt_options=ipopt_options, solver_options=solver_options, callback=callback, inner_callback=inner_callback, time_limit=time_limit, verbose=verbose, presolve=presolve, diagnostics=diagnostics, autoscale=autoscale, b_stat_max_biactive=b_stat_max_biactive, tnlp_refine=tnlp_refine, tnlp_max_iter=tnlp_max_iter, **strategy_options, ).solve()