From ede145a085db51c878f39929c20c248d99b0252c Mon Sep 17 00:00:00 2001 From: eminyouskn Date: Thu, 21 Aug 2025 19:44:03 -0400 Subject: [PATCH 01/15] Add KNITRO direct solver implementation and tests --- pyomo/contrib/solver/plugins.py | 18 +- .../contrib/solver/solvers/knitro/__init__.py | 26 + pyomo/contrib/solver/solvers/knitro/api.py | 18 + pyomo/contrib/solver/solvers/knitro/config.py | 40 ++ pyomo/contrib/solver/solvers/knitro/direct.py | 553 ++++++++++++++++++ .../tests/solvers/test_knitro_direct.py | 100 ++++ 6 files changed, 747 insertions(+), 8 deletions(-) create mode 100644 pyomo/contrib/solver/solvers/knitro/__init__.py create mode 100644 pyomo/contrib/solver/solvers/knitro/api.py create mode 100644 pyomo/contrib/solver/solvers/knitro/config.py create mode 100644 pyomo/contrib/solver/solvers/knitro/direct.py create mode 100644 pyomo/contrib/solver/tests/solvers/test_knitro_direct.py diff --git a/pyomo/contrib/solver/plugins.py b/pyomo/contrib/solver/plugins.py index 19fc9b2b2a1..e29faf9ec87 100644 --- a/pyomo/contrib/solver/plugins.py +++ b/pyomo/contrib/solver/plugins.py @@ -15,22 +15,24 @@ from .solvers.gurobi_persistent import GurobiPersistent from .solvers.gurobi_direct import GurobiDirect from .solvers.highs import Highs +from .solvers.knitro import load as load_knitro def load(): SolverFactory.register( - name='ipopt', legacy_name='ipopt_v2', doc='The IPOPT NLP solver' + name="ipopt", legacy_name="ipopt_v2", doc="The IPOPT NLP solver" )(Ipopt, LegacyIpoptSolver) SolverFactory.register( - name='gurobi_persistent', - legacy_name='gurobi_persistent_v2', - doc='Persistent interface to Gurobi', + name="gurobi_persistent", + legacy_name="gurobi_persistent_v2", + doc="Persistent interface to Gurobi", )(GurobiPersistent) SolverFactory.register( - name='gurobi_direct', - legacy_name='gurobi_direct_v2', - doc='Direct (scipy-based) interface to Gurobi', + name="gurobi_direct", + legacy_name="gurobi_direct_v2", + doc="Direct (scipy-based) interface to Gurobi", )(GurobiDirect) SolverFactory.register( - name='highs', legacy_name='highs', doc='Persistent interface to HiGHS' + name="highs", legacy_name="highs", doc="Persistent interface to HiGHS" )(Highs) + load_knitro() diff --git a/pyomo/contrib/solver/solvers/knitro/__init__.py b/pyomo/contrib/solver/solvers/knitro/__init__.py new file mode 100644 index 00000000000..eadd6c41f2c --- /dev/null +++ b/pyomo/contrib/solver/solvers/knitro/__init__.py @@ -0,0 +1,26 @@ +# ___________________________________________________________________________ +# +# Pyomo: Python Optimization Modeling Objects +# Copyright (c) 2008-2025 +# National Technology and Engineering Solutions of Sandia, LLC +# Under the terms of Contract DE-NA0003525 with National Technology and +# Engineering Solutions of Sandia, LLC, the U.S. Government retains certain +# rights in this software. +# This software is distributed under the 3-clause BSD License. +# ___________________________________________________________________________ + +from pyomo.contrib.solver.common.factory import SolverFactory + +from .config import KnitroConfig +from .direct import KnitroDirectSolver + +__all__ = ["KnitroConfig", "KnitroDirectSolver"] + + +# This function needs to be called from the plugins load function +def load(): + SolverFactory.register( + name="knitro_direct", + legacy_name="knitro_direct", + doc="Direct interface to KNITRO solver", + )(KnitroDirectSolver) diff --git a/pyomo/contrib/solver/solvers/knitro/api.py b/pyomo/contrib/solver/solvers/knitro/api.py new file mode 100644 index 00000000000..e24e7abe966 --- /dev/null +++ b/pyomo/contrib/solver/solvers/knitro/api.py @@ -0,0 +1,18 @@ +# ___________________________________________________________________________ +# +# Pyomo: Python Optimization Modeling Objects +# Copyright (c) 2008-2025 +# National Technology and Engineering Solutions of Sandia, LLC +# Under the terms of Contract DE-NA0003525 with National Technology and +# Engineering Solutions of Sandia, LLC, the U.S. Government retains certain +# rights in this software. +# This software is distributed under the 3-clause BSD License. +# ___________________________________________________________________________ + + +from pyomo.common.dependencies import attempt_import + +import knitro + +_, KNITRO_AVAILABLE = attempt_import("knitro") +KNITRO_VERSION = knitro.__version__ if KNITRO_AVAILABLE else "0.0.0" diff --git a/pyomo/contrib/solver/solvers/knitro/config.py b/pyomo/contrib/solver/solvers/knitro/config.py new file mode 100644 index 00000000000..e2e625ee919 --- /dev/null +++ b/pyomo/contrib/solver/solvers/knitro/config.py @@ -0,0 +1,40 @@ +# ___________________________________________________________________________ +# +# Pyomo: Python Optimization Modeling Objects +# Copyright (c) 2008-2025 +# National Technology and Engineering Solutions of Sandia, LLC +# Under the terms of Contract DE-NA0003525 with National Technology and +# Engineering Solutions of Sandia, LLC, the U.S. Government retains certain +# rights in this software. +# This software is distributed under the 3-clause BSD License. +# ___________________________________________________________________________ + +from pyomo.common.config import Bool, ConfigValue +from pyomo.contrib.solver.common.config import SolverConfig + + +class KnitroConfig(SolverConfig): + def __init__( + self, + description=None, + doc=None, + implicit=False, + implicit_domain=None, + visibility=0, + ): + super().__init__( + description=description, + doc=doc, + implicit=implicit, + implicit_domain=implicit_domain, + visibility=visibility, + ) + + self.rebuild_model_on_remove_var: bool = self.declare( + "rebuild_model_on_remove_var", + ConfigValue( + domain=Bool, + default=False, + doc="KNITRO solver does not allow variable removal. We can either make the variable a continuous free variable or rebuild the whole model when variable removal is attempted. When `rebuild_model_on_remove_var` is set to True, the model will be rebuilt.", + ), + ) diff --git a/pyomo/contrib/solver/solvers/knitro/direct.py b/pyomo/contrib/solver/solvers/knitro/direct.py new file mode 100644 index 00000000000..bf227aea033 --- /dev/null +++ b/pyomo/contrib/solver/solvers/knitro/direct.py @@ -0,0 +1,553 @@ +# ___________________________________________________________________________ +# +# Pyomo: Python Optimization Modeling Objects +# Copyright (c) 2008-2025 +# National Technology and Engineering Solutions of Sandia, LLC +# Under the terms of Contract DE-NA0003525 with National Technology and +# Engineering Solutions of Sandia, LLC, the U.S. Government retains certain +# rights in this software. +# This software is distributed under the 3-clause BSD License. +# ___________________________________________________________________________ + + +import io + +from collections.abc import Callable, Iterable, Mapping, MutableMapping, Sequence +from typing import Any, List, Optional, Tuple + +from pyomo.common.collections.component_map import ComponentMap +from pyomo.common.errors import ApplicationError +from pyomo.common.flags import NOTSET +from pyomo.common.numeric_types import value +from pyomo.common.tee import TeeStream, capture_output +from pyomo.common.timing import HierarchicalTimer +from pyomo.contrib.solver.common.base import Availability, SolverBase +from pyomo.contrib.solver.common.results import ( + Results, + SolutionStatus, + TerminationCondition, +) +from pyomo.contrib.solver.common.solution_loader import SolutionLoaderBase +from pyomo.contrib.solver.common.util import ( + IncompatibleModelError, + NoDualsError, + NoOptimalSolutionError, + NoSolutionError, + collect_vars_and_named_exprs, +) +from pyomo.core.base.block import BlockData +from pyomo.core.base.constraint import Constraint, ConstraintData +from pyomo.core.base.objective import Objective, ObjectiveData +from pyomo.core.base.var import VarData +from pyomo.core.plugins.transform.util import partial +from pyomo.core.staleflag import StaleFlagManager +from pyomo.repn.standard_repn import StandardRepn, generate_standard_repn + +from .api import knitro, KNITRO_AVAILABLE, KNITRO_VERSION +from .config import KnitroConfig + + +def get_active_objectives(block: BlockData) -> List[ObjectiveData]: + generator = block.component_data_objects( + Objective, descend_into=True, active=True, sort=True + ) + return list(generator) + + +def get_active_constraints(block: BlockData) -> List[ConstraintData]: + generator = block.component_data_objects( + Constraint, descend_into=True, active=True, sort=True + ) + return list(generator) + + +def get_solution_status(status: int) -> SolutionStatus: + if ( + status == knitro.KN_RC_OPTIMAL + or status == knitro.KN_RC_OPTIMAL_OR_SATISFACTORY + or status == knitro.KN_RC_NEAR_OPT + ): + return SolutionStatus.optimal + elif status == knitro.KN_RC_FEAS_NO_IMPROVE: + return SolutionStatus.feasible + elif ( + status == knitro.KN_RC_INFEASIBLE + or status == knitro.KN_RC_INFEAS_CON_BOUNDS + or status == knitro.KN_RC_INFEAS_VAR_BOUNDS + or status == knitro.KN_RC_INFEAS_NO_IMPROVE + ): + return SolutionStatus.infeasible + else: + return SolutionStatus.noSolution + + +def get_termination_condition(status: int) -> TerminationCondition: + if ( + status == knitro.KN_RC_OPTIMAL + or status == knitro.KN_RC_OPTIMAL_OR_SATISFACTORY + or status == knitro.KN_RC_NEAR_OPT + ): + return TerminationCondition.convergenceCriteriaSatisfied + elif status == knitro.KN_RC_INFEAS_NO_IMPROVE: + return TerminationCondition.locallyInfeasible + elif status == knitro.KN_RC_INFEASIBLE: + return TerminationCondition.provenInfeasible + elif status == knitro.KN_RC_UNBOUNDED_OR_INFEAS or status == knitro.KN_RC_UNBOUNDED: + return TerminationCondition.infeasibleOrUnbounded + elif ( + status == knitro.KN_RC_ITER_LIMIT_FEAS + or status == knitro.KN_RC_ITER_LIMIT_INFEAS + ): + return TerminationCondition.iterationLimit + elif ( + status == knitro.KN_RC_TIME_LIMIT_FEAS + or status == knitro.KN_RC_TIME_LIMIT_INFEAS + ): + return TerminationCondition.maxTimeLimit + elif status == knitro.KN_RC_USER_TERMINATION: + return TerminationCondition.interrupted + else: + return TerminationCondition.unknown + + +class ModelRepresentation: + """An intermediate representation of a Pyomo model. + + This class aggregates the objectives, constraints, and all referenced variables. + """ + + objs: List[ObjectiveData] + cons: List[ConstraintData] + variables: List[VarData] + + def __init__(self, objs: Iterable[ObjectiveData], cons: Iterable[ConstraintData]): + self.objs = list(objs) + self.cons = list(cons) + + # Collect all referenced variables using a dictionary to ensure uniqueness. + var_map = {} + for obj in self.objs: + _, variables, _, _ = collect_vars_and_named_exprs(obj.expr) + for v in variables: + var_map[id(v)] = v + for con in self.cons: + _, variables, _, _ = collect_vars_and_named_exprs(con.body) + for v in variables: + var_map[id(v)] = v + self.variables = list(var_map.values()) + + +def build_model_representation(block: BlockData) -> ModelRepresentation: + """Builds an intermediate representation from a Pyomo model block.""" + objs = get_active_objectives(block) + cons = get_active_constraints(block) + return ModelRepresentation(objs=objs, cons=cons) + + +class NLExpression: + """Holds the data required to evaluate a non-linear expression.""" + + body: Optional[Any] + variables: List[VarData] + + def __init__(self, expr: Optional[Any], variables: Iterable[VarData]): + self.body = expr + self.variables = list(variables) + + def create_evaluator(self, vmap: Mapping[int, int]): + def _fn(x: List[float]) -> float: + # Set the values of the Pyomo variables from the solver's vector `x` + for var in self.variables: + i = vmap[id(var)] + var.set_value(x[i]) + return value(self.body) + + return _fn + + +class KnitroLicenseManager: + """Manages the global KNITRO license context.""" + + _lmc = None + + @staticmethod + def initialize(): + if KnitroLicenseManager._lmc is None: + KnitroLicenseManager._lmc = knitro.KN_checkout_license() + return KnitroLicenseManager._lmc + + @staticmethod + def release(): + if KnitroLicenseManager._lmc is not None: + knitro.KN_release_license(KnitroLicenseManager._lmc) + KnitroLicenseManager._lmc = None + + @staticmethod + def create_new_context(): + lmc = KnitroLicenseManager.initialize() + return knitro.KN_new_lm(lmc) + + @staticmethod + def version() -> Tuple[int, int, int]: + return tuple(int(x) for x in KNITRO_VERSION.split(".")) + + @staticmethod + def available() -> Availability: + if not KNITRO_AVAILABLE: + return Availability.NotFound + try: + stream = io.StringIO() + with capture_output(TeeStream(stream), capture_fd=1): + kc = KnitroLicenseManager.create_new_context() + knitro.KN_free(kc) + # TODO: parse the stream to check the license type. + return Availability.FullLicense + except Exception: + return Availability.BadLicense + + +class KnitroProblemContext: + """ + A wrapper around the KNITRO API for a single optimization problem. + + This class manages the lifecycle of a KNITRO problem instance (`kc`), + including building the problem by adding variables and constraints, + setting options, solving, and freeing the context. + """ + + var_map: MutableMapping[int, int] + con_map: MutableMapping[int, int] + obj_nl_expr: Optional[NLExpression] + con_nl_expr_map: MutableMapping[int, NLExpression] + + def __init__(self): + self._kc = KnitroLicenseManager.create_new_context() + self.var_map = {} + self.con_map = {} + self.obj_nl_expr = None + self.con_nl_expr_map = {} + + def __del__(self): + self.close() + + def _execute(self, api_fn, *args, **kwargs): + if self._kc is None: + raise RuntimeError("KNITRO context has been freed and cannot be used.") + return api_fn(self._kc, *args, **kwargs) + + def close(self): + if self._kc is not None: + self._execute(knitro.KN_free) + self._kc = None + + def add_vars(self, variables: Iterable[VarData]): + n_vars = len(variables) + idx_vars = self._execute(knitro.KN_add_vars, n_vars) + if idx_vars is None: + return + + for i, var in zip(idx_vars, variables): + self.var_map[id(var)] = i + + var_types, fxbnds, lobnds, upbnds = {}, {}, {}, {} + for var in variables: + i = self.var_map[id(var)] + if var.is_binary(): + var_types[i] = knitro.KN_VARTYPE_BINARY + elif var.is_integer(): + var_types[i] = knitro.KN_VARTYPE_INTEGER + elif not var.is_continuous(): + msg = f"Unknown variable type for variable {var.name}." + raise ValueError(msg) + + if var.fixed: + fxbnds[i] = value(var.value) + else: + if var.has_lb(): + lobnds[i] = value(var.lb) + if var.has_ub(): + upbnds[i] = value(var.ub) + + self._execute(knitro.KN_set_var_types, var_types.keys(), var_types.values()) + self._execute(knitro.KN_set_var_fxbnds, fxbnds.keys(), fxbnds.values()) + self._execute(knitro.KN_set_var_lobnds, lobnds.keys(), lobnds.values()) + self._execute(knitro.KN_set_var_upbnds, upbnds.keys(), upbnds.values()) + + def _add_expr_structs_from_repn( + self, + repn: StandardRepn, + add_const_fn: Callable[[float], None], + add_lin_fn: Callable[[Iterable[int], Iterable[float]], None], + add_quad_fn: Callable[[Iterable[int], Iterable[int], Iterable[float]], None], + ): + if repn.constant is not None: + add_const_fn(repn.constant) + if repn.linear_vars: + idx_lin_vars = [self.var_map.get(id(v)) for v in repn.linear_vars] + add_lin_fn(idx_lin_vars, list(repn.linear_coefs)) + if repn.quadratic_vars: + quad_vars1, quad_vars2 = zip(*repn.quadratic_vars) + idx_quad_vars1 = [self.var_map.get(id(v)) for v in quad_vars1] + idx_quad_vars2 = [self.var_map.get(id(v)) for v in quad_vars2] + add_quad_fn(idx_quad_vars1, idx_quad_vars2, list(repn.quadratic_coefs)) + + def add_cons(self, cons: Iterable[ConstraintData]): + n_cons = len(cons) + idx_cons = self._execute(knitro.KN_add_cons, n_cons) + if idx_cons is None: + return + + for i, con in zip(idx_cons, cons): + self.con_map[id(con)] = i + + eqbnds, lobnds, upbnds = {}, {}, {} + for con in cons: + i = self.con_map[id(con)] + if con.equality: + eqbnds[i] = value(con.lower) + else: + if con.has_lb(): + lobnds[i] = value(con.lb) + if con.has_ub(): + upbnds[i] = value(con.ub) + + self._execute(knitro.KN_set_con_eqbnds, eqbnds.keys(), eqbnds.values()) + self._execute(knitro.KN_set_con_lobnds, lobnds.keys(), lobnds.values()) + self._execute(knitro.KN_set_con_upbnds, upbnds.keys(), upbnds.values()) + + for con in cons: + i = self.con_map[id(con)] + repn = generate_standard_repn(con.body) + self._add_expr_structs_from_repn( + repn, + add_const_fn=partial(self._execute, knitro.KN_add_con_constants, i), + add_lin_fn=partial(self._execute, knitro.KN_add_con_linear_struct, i), + add_quad_fn=partial( + self._execute, knitro.KN_add_con_quadratic_struct, i + ), + ) + if repn.nonlinear_expr is not None: + self.con_nl_expr_map[i] = NLExpression( + repn.nonlinear_expr, repn.nonlinear_vars + ) + + def set_obj(self, obj: ObjectiveData): + obj_goal = ( + knitro.KN_OBJGOAL_MINIMIZE + if obj.is_minimizing() + else knitro.KN_OBJGOAL_MAXIMIZE + ) + self._execute(knitro.KN_set_obj_goal, obj_goal) + repn = generate_standard_repn(obj.expr) + self._add_expr_structs_from_repn( + repn, + add_const_fn=partial(self._execute, knitro.KN_add_obj_constant), + add_lin_fn=partial(self._execute, knitro.KN_add_obj_linear_struct), + add_quad_fn=partial(self._execute, knitro.KN_add_obj_quadratic_struct), + ) + if repn.nonlinear_expr is not None: + self.obj_nl_expr = NLExpression(repn.nonlinear_expr, repn.nonlinear_vars) + + def _build_callback(self): + if self.obj_nl_expr is None and not self.con_nl_expr_map: + return None + + obj_eval = ( + self.obj_nl_expr.create_evaluator(self.var_map) + if self.obj_nl_expr is not None + else None + ) + con_eval_map = { + i: nl_expr.create_evaluator(self.var_map) + for i, nl_expr in self.con_nl_expr_map.items() + } + + def _callback(_, cb, req, res, data=None): + if req.type != knitro.KN_RC_EVALFC: + # This callback only handles function evaluations, not derivatives. + return -1 + x = req.x + if obj_eval is not None: + res.obj = obj_eval(x) + for i, con_eval in enumerate(con_eval_map.values()): + res.c[i] = con_eval(x) + return 0 # Return 0 for success + + return _callback + + def _register_callback(self): + callback_fn = self._build_callback() + if callback_fn is not None: + eval_obj = self.obj_nl_expr is not None + idx_cons = list(self.con_nl_expr_map.keys()) + self._execute(knitro.KN_add_eval_callback, eval_obj, idx_cons, callback_fn) + + def solve(self) -> int: + self._register_callback() + return self._execute(knitro.KN_solve) + + def get_num_iters(self) -> int: + return self._execute(knitro.KN_get_number_iters) + + def get_solve_time(self) -> float: + return self._execute(knitro.KN_get_solve_time_real) + + def get_primals(self, variables: Iterable[VarData]) -> Optional[List[float]]: + idx_vars = [self.var_map.get(id(var)) for var in variables] + return self._execute(knitro.KN_get_var_primal_values, idx_vars) + + def get_duals(self, cons: Iterable[ConstraintData]) -> Optional[List[float]]: + idx_cons = [self.con_map.get(id(con)) for con in cons] + return self._execute(knitro.KN_get_con_dual_values, idx_cons) + + def set_options(self, **options): + for param, val in options.items(): + param_id = self._execute(knitro.KN_get_param_id, param) + param_type = self._execute(knitro.KN_get_param_type, param_id) + if param_type == knitro.KN_PARAMTYPE_INTEGER: + setter_fn = knitro.KN_set_int_param + elif param_type == knitro.KN_PARAMTYPE_FLOAT: + setter_fn = knitro.KN_set_double_param + else: + setter_fn = knitro.KN_set_char_param + self._execute(setter_fn, param_id, val) + + def set_outlev(self, level: int = knitro.KN_OUTLEV_ALL): + self.set_options(outlev=level) + + def set_time_limit(self, time_limit: float): + self.set_options(maxtime_cpu=time_limit) + + def set_num_threads(self, num_threads: int): + self.set_options(numthreads=num_threads) + + +class KnitroDirectSolutionLoader(SolutionLoaderBase): + def __init__(self, problem: KnitroProblemContext, model_repn: ModelRepresentation): + super().__init__() + self._problem = problem + self._model_repn = model_repn + + def get_number_of_solutions(self) -> int: + _, _, x, _ = self._problem._execute(knitro.KN_get_solution) + return 1 if x is not None else 0 + + def get_vars( + self, vars_to_load: Optional[Sequence[VarData]] = None + ) -> Mapping[VarData, float]: + if vars_to_load is None: + vars_to_load = self._model_repn.variables + + x = self._problem.get_primals(vars_to_load) + if x is None: + return NoSolutionError() + return ComponentMap([(var, x[i]) for i, var in enumerate(vars_to_load)]) + + def get_duals( + self, cons_to_load: Optional[Sequence[ConstraintData]] = None + ) -> Mapping[ConstraintData, float]: + if cons_to_load is None: + cons_to_load = self._model_repn.cons + + y = self._problem.get_duals(cons_to_load) + if y is None: + return NoDualsError() + return ComponentMap([(con, y[i]) for i, con in enumerate(cons_to_load)]) + + +class KnitroDirectSolver(SolverBase): + NAME = "KNITRO" + CONFIG = KnitroConfig() + config: KnitroConfig + + def __init__(self, **kwds): + super().__init__(**kwds) + self._available_cache = NOTSET + + def available(self) -> Availability: + if self._available_cache is NOTSET: + self._available_cache = KnitroLicenseManager.available() + return self._available_cache + + def version(self): + return KnitroLicenseManager.version() + + def _build_config(self, **kwds) -> KnitroConfig: + return self.config(value=kwds, preserve_implicit=True) + + def _validate_model(self, model_repn: ModelRepresentation): + if len(model_repn.objs) > 1: + raise IncompatibleModelError( + f"{self.NAME} does not support multiple objectives." + ) + + def solve(self, model: BlockData, **kwds) -> Results: + config = self._build_config(**kwds) + timer = config.timer or HierarchicalTimer() + + avail = self.available() + if not avail: + raise ApplicationError(f"Solver {self.NAME} is not available: {avail}.") + + StaleFlagManager.mark_all_as_stale() + timer.start("build_model_representation") + model_repn = build_model_representation(model) + timer.stop("build_model_representation") + + self._validate_model(model_repn) + + stream = io.StringIO() + ostreams = [stream] + config.tee + with capture_output(TeeStream(*ostreams), capture_fd=False): + problem = KnitroProblemContext() + + timer.start("add_vars") + problem.add_vars(model_repn.variables) + timer.stop("add_vars") + + timer.start("add_cons") + problem.add_cons(model_repn.cons) + timer.stop("add_cons") + + if model_repn.objs: + timer.start("set_objective") + problem.set_obj(model_repn.objs[0]) + timer.stop("set_objective") + + problem.set_outlev() + if config.threads is not None: + problem.set_num_threads(config.threads) + if config.time_limit is not None: + problem.set_time_limit(config.time_limit) + + timer.start("load_options") + problem.set_options(**config.solver_options) + timer.stop("load_options") + + timer.start("solve") + status = problem.solve() + timer.stop("solve") + + results = Results() + results.solver_config = config + results.solver_name = self.NAME + results.solver_version = self.version() + results.solver_log = stream.getvalue() + results.iteration_count = problem.get_num_iters() + results.solution_status = get_solution_status(status) + results.termination_condition = get_termination_condition(status) + if ( + config.raise_exception_on_nonoptimal_result + and results.termination_condition + != TerminationCondition.convergenceCriteriaSatisfied + ): + raise NoOptimalSolutionError() + + results.solution_loader = KnitroDirectSolutionLoader(problem, model_repn) + if config.load_solutions: + timer.start("load_solutions") + results.solution_loader.load_vars() + timer.stop("load_solutions") + + results.timing_info.solve_time = problem.get_solve_time() + return results diff --git a/pyomo/contrib/solver/tests/solvers/test_knitro_direct.py b/pyomo/contrib/solver/tests/solvers/test_knitro_direct.py new file mode 100644 index 00000000000..b025681c9a3 --- /dev/null +++ b/pyomo/contrib/solver/tests/solvers/test_knitro_direct.py @@ -0,0 +1,100 @@ +# ___________________________________________________________________________ +# +# Pyomo: Python Optimization Modeling Objects +# Copyright (c) 2008-2025 +# National Technology and Engineering Solutions of Sandia, LLC +# Under the terms of Contract DE-NA0003525 with National Technology and +# Engineering Solutions of Sandia, LLC, the U.S. Government retains certain +# rights in this software. +# This software is distributed under the 3-clause BSD License. +# ___________________________________________________________________________ + +import unittest +import pyomo.environ as pyo +import pyomo.contrib.solver.solvers.knitro as knitro + +avail = knitro.KnitroDirectSolver().available() + + +@unittest.skipIf(not avail, "KNITRO solver is not available") +class TestKnitroDirectSolverConfig(unittest.TestCase): + def test_default_instantiation(self): + config = knitro.KnitroConfig() + self.assertIsNone(config._description) + self.assertEqual(config._visibility, 0) + self.assertFalse(config.tee) + self.assertTrue(config.load_solutions) + self.assertTrue(config.raise_exception_on_nonoptimal_result) + self.assertFalse(config.symbolic_solver_labels) + self.assertIsNone(config.timer) + self.assertIsNone(config.threads) + self.assertIsNone(config.time_limit) + + def test_custom_instantiation(self): + config = knitro.KnitroConfig(description="A description") + config.tee = True + self.assertTrue(config.tee) + self.assertEqual(config._description, "A description") + self.assertIsNone(config.time_limit) + + +@unittest.skipIf(not avail, "KNITRO solver is not available") +class TestKnitroDirectSolverInterface(unittest.TestCase): + def test_class_member_list(self): + opt = knitro.KnitroDirectSolver() + expected_list = [ + "CONFIG", + "available", + "config", + "api_version", + "is_persistent", + "name", + "NAME", + "solve", + "version", + ] + method_list = [m for m in dir(opt) if not m.startswith("_")] + self.assertListEqual(sorted(method_list), sorted(expected_list)) + + def test_default_instantiation(self): + opt = knitro.KnitroDirectSolver() + self.assertFalse(opt.is_persistent()) + self.assertIsNotNone(opt.version()) + self.assertEqual(opt.name, "knitrodirectsolver") + self.assertEqual(opt.CONFIG, opt.config) + self.assertTrue(opt.available()) + + def test_instantiation_as_context(self): + with knitro.KnitroDirectSolver() as opt: + self.assertFalse(opt.is_persistent()) + self.assertIsNotNone(opt.version()) + self.assertEqual(opt.name, "knitrodirectsolver") + self.assertEqual(opt.CONFIG, opt.config) + self.assertTrue(opt.available()) + + def test_available_cache(self): + opt = knitro.KnitroDirectSolver() + opt.available() + self.assertTrue(opt._available_cache) + self.assertIsNotNone(opt._available_cache) + + +class TestKnitroDirectSolver(unittest.TestCase): + def create_model(self): + model = pyo.ConcreteModel() + model.x = pyo.Var(initialize=1.5, bounds=(-5, 5)) + model.y = pyo.Var(initialize=1.5, bounds=(-5, 5)) + + def dummy_equation(m): + return (1.0 - m.x) + 100.0 * (m.y - m.x) + + model.obj = pyo.Objective(rule=dummy_equation, sense=pyo.minimize) + return model + + def test_solve(self): + model = self.create_model() + opt = knitro.KnitroDirectSolver() + results = opt.solve(model) + results.solution_loader.load_vars() + self.assertAlmostEqual(model.x.value, 5) + self.assertAlmostEqual(model.y.value, -5) From c64f194a1f9f3ba4912164b90a38fd87d09a4240 Mon Sep 17 00:00:00 2001 From: eminyouskn Date: Thu, 21 Aug 2025 20:00:07 -0400 Subject: [PATCH 02/15] Refactor Knitro API imports and update solver name in tests --- pyomo/contrib/solver/solvers/knitro/api.py | 4 ++-- pyomo/contrib/solver/solvers/knitro/direct.py | 4 ++++ pyomo/contrib/solver/tests/solvers/test_knitro_direct.py | 7 +++---- 3 files changed, 9 insertions(+), 6 deletions(-) diff --git a/pyomo/contrib/solver/solvers/knitro/api.py b/pyomo/contrib/solver/solvers/knitro/api.py index e24e7abe966..a3f7d7eae9d 100644 --- a/pyomo/contrib/solver/solvers/knitro/api.py +++ b/pyomo/contrib/solver/solvers/knitro/api.py @@ -12,7 +12,7 @@ from pyomo.common.dependencies import attempt_import -import knitro +# import knitro -_, KNITRO_AVAILABLE = attempt_import("knitro") +knitro, KNITRO_AVAILABLE = attempt_import("knitro") KNITRO_VERSION = knitro.__version__ if KNITRO_AVAILABLE else "0.0.0" diff --git a/pyomo/contrib/solver/solvers/knitro/direct.py b/pyomo/contrib/solver/solvers/knitro/direct.py index bf227aea033..0cbabdf14f8 100644 --- a/pyomo/contrib/solver/solvers/knitro/direct.py +++ b/pyomo/contrib/solver/solvers/knitro/direct.py @@ -443,6 +443,10 @@ def get_vars( return NoSolutionError() return ComponentMap([(var, x[i]) for i, var in enumerate(vars_to_load)]) + # TODO: remove this when the solution loader is fixed. + def get_primals(self, vars_to_load=None): + return self.get_vars(vars_to_load) + def get_duals( self, cons_to_load: Optional[Sequence[ConstraintData]] = None ) -> Mapping[ConstraintData, float]: diff --git a/pyomo/contrib/solver/tests/solvers/test_knitro_direct.py b/pyomo/contrib/solver/tests/solvers/test_knitro_direct.py index b025681c9a3..c630a42f9e9 100644 --- a/pyomo/contrib/solver/tests/solvers/test_knitro_direct.py +++ b/pyomo/contrib/solver/tests/solvers/test_knitro_direct.py @@ -60,7 +60,7 @@ def test_default_instantiation(self): opt = knitro.KnitroDirectSolver() self.assertFalse(opt.is_persistent()) self.assertIsNotNone(opt.version()) - self.assertEqual(opt.name, "knitrodirectsolver") + self.assertEqual(opt.name, "knitro_direct") self.assertEqual(opt.CONFIG, opt.config) self.assertTrue(opt.available()) @@ -68,7 +68,7 @@ def test_instantiation_as_context(self): with knitro.KnitroDirectSolver() as opt: self.assertFalse(opt.is_persistent()) self.assertIsNotNone(opt.version()) - self.assertEqual(opt.name, "knitrodirectsolver") + self.assertEqual(opt.name, "knitro_direct") self.assertEqual(opt.CONFIG, opt.config) self.assertTrue(opt.available()) @@ -94,7 +94,6 @@ def dummy_equation(m): def test_solve(self): model = self.create_model() opt = knitro.KnitroDirectSolver() - results = opt.solve(model) - results.solution_loader.load_vars() + opt.solve(model) self.assertAlmostEqual(model.x.value, 5) self.assertAlmostEqual(model.y.value, -5) From f6be0fec15ca11560b600766b891b5613f0b89ae Mon Sep 17 00:00:00 2001 From: eminyouskn Date: Thu, 21 Aug 2025 20:28:42 -0400 Subject: [PATCH 03/15] Add objective value retrieval and quadratic programming test for Knitro solver --- pyomo/contrib/solver/solvers/knitro/direct.py | 6 +++++- .../tests/solvers/test_knitro_direct.py | 19 +++++++++++++++++++ 2 files changed, 24 insertions(+), 1 deletion(-) diff --git a/pyomo/contrib/solver/solvers/knitro/direct.py b/pyomo/contrib/solver/solvers/knitro/direct.py index 0cbabdf14f8..021feab72cc 100644 --- a/pyomo/contrib/solver/solvers/knitro/direct.py +++ b/pyomo/contrib/solver/solvers/knitro/direct.py @@ -400,6 +400,9 @@ def get_duals(self, cons: Iterable[ConstraintData]) -> Optional[List[float]]: idx_cons = [self.con_map.get(id(con)) for con in cons] return self._execute(knitro.KN_get_con_dual_values, idx_cons) + def get_obj_value(self) -> Optional[float]: + return self._execute(knitro.KN_get_obj_value) + def set_options(self, **options): for param, val in options.items(): param_id = self._execute(knitro.KN_get_param_id, param) @@ -546,6 +549,8 @@ def solve(self, model: BlockData, **kwds) -> Results: != TerminationCondition.convergenceCriteriaSatisfied ): raise NoOptimalSolutionError() + results.timing_info.solve_time = problem.get_solve_time() + results.incumbent_objective = problem.get_obj_value() results.solution_loader = KnitroDirectSolutionLoader(problem, model_repn) if config.load_solutions: @@ -553,5 +558,4 @@ def solve(self, model: BlockData, **kwds) -> Results: results.solution_loader.load_vars() timer.stop("load_solutions") - results.timing_info.solve_time = problem.get_solve_time() return results diff --git a/pyomo/contrib/solver/tests/solvers/test_knitro_direct.py b/pyomo/contrib/solver/tests/solvers/test_knitro_direct.py index c630a42f9e9..f6d29993931 100644 --- a/pyomo/contrib/solver/tests/solvers/test_knitro_direct.py +++ b/pyomo/contrib/solver/tests/solvers/test_knitro_direct.py @@ -91,9 +91,28 @@ def dummy_equation(m): model.obj = pyo.Objective(rule=dummy_equation, sense=pyo.minimize) return model + def create_qp_model(self): + model = pyo.ConcreteModel() + model.x = pyo.Var(initialize=1.5, bounds=(-5, 5)) + model.y = pyo.Var(initialize=1.5, bounds=(-5, 5)) + + def dummy_qp_equation(m): + return (1.0 - m.x) + 100.0 * (m.y - m.x) ** 2 + + model.obj = pyo.Objective(rule=dummy_qp_equation, sense=pyo.minimize) + return model + def test_solve(self): model = self.create_model() opt = knitro.KnitroDirectSolver() opt.solve(model) self.assertAlmostEqual(model.x.value, 5) self.assertAlmostEqual(model.y.value, -5) + + def test_qp_solve(self): + model = self.create_qp_model() + opt = knitro.KnitroDirectSolver() + results = opt.solve(model) + self.assertAlmostEqual(results.incumbent_objective, -4.0, 3) + self.assertAlmostEqual(model.x.value, 5.0, 3) + self.assertAlmostEqual(model.y.value, 5.0, 3) From 68be80a05853427c8100511f29044f0c3788307d Mon Sep 17 00:00:00 2001 From: eminyouskn Date: Fri, 22 Aug 2025 10:13:37 -0400 Subject: [PATCH 04/15] Add QCP test to Knitro direct solver --- .../tests/solvers/test_knitro_direct.py | 21 +++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/pyomo/contrib/solver/tests/solvers/test_knitro_direct.py b/pyomo/contrib/solver/tests/solvers/test_knitro_direct.py index f6d29993931..c1e29fc34ff 100644 --- a/pyomo/contrib/solver/tests/solvers/test_knitro_direct.py +++ b/pyomo/contrib/solver/tests/solvers/test_knitro_direct.py @@ -102,6 +102,21 @@ def dummy_qp_equation(m): model.obj = pyo.Objective(rule=dummy_qp_equation, sense=pyo.minimize) return model + def create_qcp_model(self): + model = pyo.ConcreteModel() + model.x = pyo.Var(initialize=1.5, bounds=(-5, 5)) + model.y = pyo.Var(initialize=1.5, bounds=(-5, 5)) + + def dummy_qcp_equation(m): + return (m.y - m.x) ** 2 + + def dummy_qcp_constraint(m): + return m.x**2 + m.y**2 <= 4 + + model.obj = pyo.Objective(rule=dummy_qcp_equation, sense=pyo.minimize) + model.c1 = pyo.Constraint(rule=dummy_qcp_constraint) + return model + def test_solve(self): model = self.create_model() opt = knitro.KnitroDirectSolver() @@ -116,3 +131,9 @@ def test_qp_solve(self): self.assertAlmostEqual(results.incumbent_objective, -4.0, 3) self.assertAlmostEqual(model.x.value, 5.0, 3) self.assertAlmostEqual(model.y.value, 5.0, 3) + + def test_qcp_solve(self): + model = self.create_qcp_model() + opt = knitro.KnitroDirectSolver() + results = opt.solve(model) + self.assertAlmostEqual(results.incumbent_objective, 0.0) From 5ae4e066fa6a5ba63481135ce0fa531fb1b91a4f Mon Sep 17 00:00:00 2001 From: eminyouskn Date: Fri, 22 Aug 2025 10:59:29 -0400 Subject: [PATCH 05/15] Add non linear tests. --- .../tests/solvers/test_knitro_direct.py | 101 +++++++++--------- 1 file changed, 52 insertions(+), 49 deletions(-) diff --git a/pyomo/contrib/solver/tests/solvers/test_knitro_direct.py b/pyomo/contrib/solver/tests/solvers/test_knitro_direct.py index c1e29fc34ff..16fabb5a5a1 100644 --- a/pyomo/contrib/solver/tests/solvers/test_knitro_direct.py +++ b/pyomo/contrib/solver/tests/solvers/test_knitro_direct.py @@ -80,60 +80,63 @@ def test_available_cache(self): class TestKnitroDirectSolver(unittest.TestCase): - def create_model(self): - model = pyo.ConcreteModel() - model.x = pyo.Var(initialize=1.5, bounds=(-5, 5)) - model.y = pyo.Var(initialize=1.5, bounds=(-5, 5)) - - def dummy_equation(m): - return (1.0 - m.x) + 100.0 * (m.y - m.x) - - model.obj = pyo.Objective(rule=dummy_equation, sense=pyo.minimize) - return model - - def create_qp_model(self): - model = pyo.ConcreteModel() - model.x = pyo.Var(initialize=1.5, bounds=(-5, 5)) - model.y = pyo.Var(initialize=1.5, bounds=(-5, 5)) - - def dummy_qp_equation(m): - return (1.0 - m.x) + 100.0 * (m.y - m.x) ** 2 - - model.obj = pyo.Objective(rule=dummy_qp_equation, sense=pyo.minimize) - return model - - def create_qcp_model(self): - model = pyo.ConcreteModel() - model.x = pyo.Var(initialize=1.5, bounds=(-5, 5)) - model.y = pyo.Var(initialize=1.5, bounds=(-5, 5)) - - def dummy_qcp_equation(m): - return (m.y - m.x) ** 2 - - def dummy_qcp_constraint(m): - return m.x**2 + m.y**2 <= 4 - - model.obj = pyo.Objective(rule=dummy_qcp_equation, sense=pyo.minimize) - model.c1 = pyo.Constraint(rule=dummy_qcp_constraint) - return model + def setUp(self): + self.opt = knitro.KnitroDirectSolver() def test_solve(self): - model = self.create_model() - opt = knitro.KnitroDirectSolver() - opt.solve(model) - self.assertAlmostEqual(model.x.value, 5) - self.assertAlmostEqual(model.y.value, -5) + m = pyo.ConcreteModel() + m.x = pyo.Var(initialize=1.5, bounds=(-5, 5)) + m.y = pyo.Var(initialize=1.5, bounds=(-5, 5)) + m.obj = pyo.Objective( + expr=(1.0 - m.x) + 100.0 * (m.y - m.x), + sense=pyo.minimize, + ) + res = self.opt.solve(m) + self.assertAlmostEqual(res.incumbent_objective, -1004) + self.assertAlmostEqual(m.x.value, 5) + self.assertAlmostEqual(m.y.value, -5) def test_qp_solve(self): - model = self.create_qp_model() - opt = knitro.KnitroDirectSolver() - results = opt.solve(model) + m = pyo.ConcreteModel() + m.x = pyo.Var(initialize=1.5, bounds=(-5, 5)) + m.y = pyo.Var(initialize=1.5, bounds=(-5, 5)) + m.obj = pyo.Objective( + expr=(1.0 - m.x) + 100.0 * (m.y - m.x) ** 2, + sense=pyo.minimize, + ) + results = self.opt.solve(m) self.assertAlmostEqual(results.incumbent_objective, -4.0, 3) - self.assertAlmostEqual(model.x.value, 5.0, 3) - self.assertAlmostEqual(model.y.value, 5.0, 3) + self.assertAlmostEqual(m.x.value, 5.0, 3) + self.assertAlmostEqual(m.y.value, 5.0, 3) def test_qcp_solve(self): - model = self.create_qcp_model() - opt = knitro.KnitroDirectSolver() - results = opt.solve(model) + m = pyo.ConcreteModel() + m.x = pyo.Var(initialize=1.5, bounds=(-5, 5)) + m.y = pyo.Var(initialize=1.5, bounds=(-5, 5)) + m.obj = pyo.Objective( + expr=(m.y - m.x) ** 2, + sense=pyo.minimize, + ) + m.c1 = pyo.Constraint(expr=m.x**2 + m.y**2 <= 4) + results = self.opt.solve(m) self.assertAlmostEqual(results.incumbent_objective, 0.0) + + def test_solve_exp(self): + m = pyo.ConcreteModel() + m.x = pyo.Var() + m.y = pyo.Var() + m.obj = pyo.Objective(expr=m.x**2 + m.y**2) + m.c1 = pyo.Constraint(expr=m.y >= pyo.exp(m.x)) + self.opt.solve(m) + self.assertAlmostEqual(m.x.value, -0.42630274815985264) + self.assertAlmostEqual(m.y.value, 0.6529186341994245) + + def test_solve_log(self): + m = pyo.ConcreteModel() + m.x = pyo.Var(initialize=1) + m.y = pyo.Var() + m.obj = pyo.Objective(expr=m.x**2 + m.y**2) + m.c1 = pyo.Constraint(expr=m.y <= pyo.log(m.x)) + self.opt.solve(m) + self.assertAlmostEqual(m.x.value, 0.6529186341994245) + self.assertAlmostEqual(m.y.value, -0.42630274815985264) From 8c82a40487384dcabd5eba95b36237dfe824df77 Mon Sep 17 00:00:00 2001 From: eminyouskn Date: Fri, 22 Aug 2025 13:10:37 -0400 Subject: [PATCH 06/15] run black. --- .../solver/tests/solvers/test_knitro_direct.py | 11 +++-------- 1 file changed, 3 insertions(+), 8 deletions(-) diff --git a/pyomo/contrib/solver/tests/solvers/test_knitro_direct.py b/pyomo/contrib/solver/tests/solvers/test_knitro_direct.py index 16fabb5a5a1..515c7fce2bc 100644 --- a/pyomo/contrib/solver/tests/solvers/test_knitro_direct.py +++ b/pyomo/contrib/solver/tests/solvers/test_knitro_direct.py @@ -88,8 +88,7 @@ def test_solve(self): m.x = pyo.Var(initialize=1.5, bounds=(-5, 5)) m.y = pyo.Var(initialize=1.5, bounds=(-5, 5)) m.obj = pyo.Objective( - expr=(1.0 - m.x) + 100.0 * (m.y - m.x), - sense=pyo.minimize, + expr=(1.0 - m.x) + 100.0 * (m.y - m.x), sense=pyo.minimize ) res = self.opt.solve(m) self.assertAlmostEqual(res.incumbent_objective, -1004) @@ -101,8 +100,7 @@ def test_qp_solve(self): m.x = pyo.Var(initialize=1.5, bounds=(-5, 5)) m.y = pyo.Var(initialize=1.5, bounds=(-5, 5)) m.obj = pyo.Objective( - expr=(1.0 - m.x) + 100.0 * (m.y - m.x) ** 2, - sense=pyo.minimize, + expr=(1.0 - m.x) + 100.0 * (m.y - m.x) ** 2, sense=pyo.minimize ) results = self.opt.solve(m) self.assertAlmostEqual(results.incumbent_objective, -4.0, 3) @@ -113,10 +111,7 @@ def test_qcp_solve(self): m = pyo.ConcreteModel() m.x = pyo.Var(initialize=1.5, bounds=(-5, 5)) m.y = pyo.Var(initialize=1.5, bounds=(-5, 5)) - m.obj = pyo.Objective( - expr=(m.y - m.x) ** 2, - sense=pyo.minimize, - ) + m.obj = pyo.Objective(expr=(m.y - m.x) ** 2, sense=pyo.minimize) m.c1 = pyo.Constraint(expr=m.x**2 + m.y**2 <= 4) results = self.opt.solve(m) self.assertAlmostEqual(results.incumbent_objective, 0.0) From 77f676b80457ab2f1a8758949500435e3e8fef1f Mon Sep 17 00:00:00 2001 From: eminyouskn Date: Fri, 22 Aug 2025 13:14:53 -0400 Subject: [PATCH 07/15] Sort imports. --- pyomo/contrib/solver/solvers/knitro/direct.py | 5 ++--- pyomo/contrib/solver/tests/solvers/test_knitro_direct.py | 3 ++- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/pyomo/contrib/solver/solvers/knitro/direct.py b/pyomo/contrib/solver/solvers/knitro/direct.py index 021feab72cc..d28966738ae 100644 --- a/pyomo/contrib/solver/solvers/knitro/direct.py +++ b/pyomo/contrib/solver/solvers/knitro/direct.py @@ -11,11 +11,10 @@ import io - from collections.abc import Callable, Iterable, Mapping, MutableMapping, Sequence from typing import Any, List, Optional, Tuple -from pyomo.common.collections.component_map import ComponentMap +from pyomo.common.collections import ComponentMap from pyomo.common.errors import ApplicationError from pyomo.common.flags import NOTSET from pyomo.common.numeric_types import value @@ -43,7 +42,7 @@ from pyomo.core.staleflag import StaleFlagManager from pyomo.repn.standard_repn import StandardRepn, generate_standard_repn -from .api import knitro, KNITRO_AVAILABLE, KNITRO_VERSION +from .api import KNITRO_AVAILABLE, KNITRO_VERSION, knitro from .config import KnitroConfig diff --git a/pyomo/contrib/solver/tests/solvers/test_knitro_direct.py b/pyomo/contrib/solver/tests/solvers/test_knitro_direct.py index 515c7fce2bc..0c3d927c4d0 100644 --- a/pyomo/contrib/solver/tests/solvers/test_knitro_direct.py +++ b/pyomo/contrib/solver/tests/solvers/test_knitro_direct.py @@ -10,8 +10,9 @@ # ___________________________________________________________________________ import unittest -import pyomo.environ as pyo + import pyomo.contrib.solver.solvers.knitro as knitro +import pyomo.environ as pyo avail = knitro.KnitroDirectSolver().available() From 8d62ca04322f03def9295b156f60550c46d11329 Mon Sep 17 00:00:00 2001 From: eminyouskn Date: Mon, 25 Aug 2025 13:44:16 -0400 Subject: [PATCH 08/15] Refactor code. --- .../contrib/solver/solvers/knitro/__init__.py | 2 +- pyomo/contrib/solver/solvers/knitro/direct.py | 477 ++---------------- pyomo/contrib/solver/solvers/knitro/engine.py | 308 +++++++++++ pyomo/contrib/solver/solvers/knitro/mixin.py | 106 ++++ pyomo/contrib/solver/solvers/knitro/utils.py | 125 +++++ pyomo/duality/tests/test_t1_result.lp | 31 ++ pyomo/duality/tests/test_t5_result.lp | 29 ++ 7 files changed, 642 insertions(+), 436 deletions(-) create mode 100644 pyomo/contrib/solver/solvers/knitro/engine.py create mode 100644 pyomo/contrib/solver/solvers/knitro/mixin.py create mode 100644 pyomo/contrib/solver/solvers/knitro/utils.py create mode 100644 pyomo/duality/tests/test_t1_result.lp create mode 100644 pyomo/duality/tests/test_t5_result.lp diff --git a/pyomo/contrib/solver/solvers/knitro/__init__.py b/pyomo/contrib/solver/solvers/knitro/__init__.py index eadd6c41f2c..d6acd9d4f3c 100644 --- a/pyomo/contrib/solver/solvers/knitro/__init__.py +++ b/pyomo/contrib/solver/solvers/knitro/__init__.py @@ -12,7 +12,7 @@ from pyomo.contrib.solver.common.factory import SolverFactory from .config import KnitroConfig -from .direct import KnitroDirectSolver +from .direct import Solver as KnitroDirectSolver __all__ = ["KnitroConfig", "KnitroDirectSolver"] diff --git a/pyomo/contrib/solver/solvers/knitro/direct.py b/pyomo/contrib/solver/solvers/knitro/direct.py index d28966738ae..0d474548f53 100644 --- a/pyomo/contrib/solver/solvers/knitro/direct.py +++ b/pyomo/contrib/solver/solvers/knitro/direct.py @@ -11,436 +11,51 @@ import io -from collections.abc import Callable, Iterable, Mapping, MutableMapping, Sequence -from typing import Any, List, Optional, Tuple +from collections.abc import Mapping, Sequence +from typing import Optional from pyomo.common.collections import ComponentMap from pyomo.common.errors import ApplicationError from pyomo.common.flags import NOTSET -from pyomo.common.numeric_types import value from pyomo.common.tee import TeeStream, capture_output from pyomo.common.timing import HierarchicalTimer from pyomo.contrib.solver.common.base import Availability, SolverBase -from pyomo.contrib.solver.common.results import ( - Results, - SolutionStatus, - TerminationCondition, -) +from pyomo.contrib.solver.common.results import Results, TerminationCondition from pyomo.contrib.solver.common.solution_loader import SolutionLoaderBase from pyomo.contrib.solver.common.util import ( IncompatibleModelError, NoDualsError, NoOptimalSolutionError, NoSolutionError, - collect_vars_and_named_exprs, ) from pyomo.core.base.block import BlockData -from pyomo.core.base.constraint import Constraint, ConstraintData -from pyomo.core.base.objective import Objective, ObjectiveData +from pyomo.core.base.constraint import ConstraintData from pyomo.core.base.var import VarData -from pyomo.core.plugins.transform.util import partial from pyomo.core.staleflag import StaleFlagManager -from pyomo.repn.standard_repn import StandardRepn, generate_standard_repn -from .api import KNITRO_AVAILABLE, KNITRO_VERSION, knitro +from .api import knitro +from .mixin import License, SolverMixin from .config import KnitroConfig +from .engine import Engine +from .utils import ProblemData -def get_active_objectives(block: BlockData) -> List[ObjectiveData]: - generator = block.component_data_objects( - Objective, descend_into=True, active=True, sort=True - ) - return list(generator) - - -def get_active_constraints(block: BlockData) -> List[ConstraintData]: - generator = block.component_data_objects( - Constraint, descend_into=True, active=True, sort=True - ) - return list(generator) - - -def get_solution_status(status: int) -> SolutionStatus: - if ( - status == knitro.KN_RC_OPTIMAL - or status == knitro.KN_RC_OPTIMAL_OR_SATISFACTORY - or status == knitro.KN_RC_NEAR_OPT - ): - return SolutionStatus.optimal - elif status == knitro.KN_RC_FEAS_NO_IMPROVE: - return SolutionStatus.feasible - elif ( - status == knitro.KN_RC_INFEASIBLE - or status == knitro.KN_RC_INFEAS_CON_BOUNDS - or status == knitro.KN_RC_INFEAS_VAR_BOUNDS - or status == knitro.KN_RC_INFEAS_NO_IMPROVE - ): - return SolutionStatus.infeasible - else: - return SolutionStatus.noSolution - - -def get_termination_condition(status: int) -> TerminationCondition: - if ( - status == knitro.KN_RC_OPTIMAL - or status == knitro.KN_RC_OPTIMAL_OR_SATISFACTORY - or status == knitro.KN_RC_NEAR_OPT - ): - return TerminationCondition.convergenceCriteriaSatisfied - elif status == knitro.KN_RC_INFEAS_NO_IMPROVE: - return TerminationCondition.locallyInfeasible - elif status == knitro.KN_RC_INFEASIBLE: - return TerminationCondition.provenInfeasible - elif status == knitro.KN_RC_UNBOUNDED_OR_INFEAS or status == knitro.KN_RC_UNBOUNDED: - return TerminationCondition.infeasibleOrUnbounded - elif ( - status == knitro.KN_RC_ITER_LIMIT_FEAS - or status == knitro.KN_RC_ITER_LIMIT_INFEAS - ): - return TerminationCondition.iterationLimit - elif ( - status == knitro.KN_RC_TIME_LIMIT_FEAS - or status == knitro.KN_RC_TIME_LIMIT_INFEAS - ): - return TerminationCondition.maxTimeLimit - elif status == knitro.KN_RC_USER_TERMINATION: - return TerminationCondition.interrupted - else: - return TerminationCondition.unknown - - -class ModelRepresentation: - """An intermediate representation of a Pyomo model. - - This class aggregates the objectives, constraints, and all referenced variables. - """ - - objs: List[ObjectiveData] - cons: List[ConstraintData] - variables: List[VarData] - - def __init__(self, objs: Iterable[ObjectiveData], cons: Iterable[ConstraintData]): - self.objs = list(objs) - self.cons = list(cons) - - # Collect all referenced variables using a dictionary to ensure uniqueness. - var_map = {} - for obj in self.objs: - _, variables, _, _ = collect_vars_and_named_exprs(obj.expr) - for v in variables: - var_map[id(v)] = v - for con in self.cons: - _, variables, _, _ = collect_vars_and_named_exprs(con.body) - for v in variables: - var_map[id(v)] = v - self.variables = list(var_map.values()) - - -def build_model_representation(block: BlockData) -> ModelRepresentation: - """Builds an intermediate representation from a Pyomo model block.""" - objs = get_active_objectives(block) - cons = get_active_constraints(block) - return ModelRepresentation(objs=objs, cons=cons) - - -class NLExpression: - """Holds the data required to evaluate a non-linear expression.""" - - body: Optional[Any] - variables: List[VarData] - - def __init__(self, expr: Optional[Any], variables: Iterable[VarData]): - self.body = expr - self.variables = list(variables) - - def create_evaluator(self, vmap: Mapping[int, int]): - def _fn(x: List[float]) -> float: - # Set the values of the Pyomo variables from the solver's vector `x` - for var in self.variables: - i = vmap[id(var)] - var.set_value(x[i]) - return value(self.body) - - return _fn - - -class KnitroLicenseManager: - """Manages the global KNITRO license context.""" - - _lmc = None - - @staticmethod - def initialize(): - if KnitroLicenseManager._lmc is None: - KnitroLicenseManager._lmc = knitro.KN_checkout_license() - return KnitroLicenseManager._lmc - - @staticmethod - def release(): - if KnitroLicenseManager._lmc is not None: - knitro.KN_release_license(KnitroLicenseManager._lmc) - KnitroLicenseManager._lmc = None - - @staticmethod - def create_new_context(): - lmc = KnitroLicenseManager.initialize() - return knitro.KN_new_lm(lmc) - - @staticmethod - def version() -> Tuple[int, int, int]: - return tuple(int(x) for x in KNITRO_VERSION.split(".")) - - @staticmethod - def available() -> Availability: - if not KNITRO_AVAILABLE: - return Availability.NotFound - try: - stream = io.StringIO() - with capture_output(TeeStream(stream), capture_fd=1): - kc = KnitroLicenseManager.create_new_context() - knitro.KN_free(kc) - # TODO: parse the stream to check the license type. - return Availability.FullLicense - except Exception: - return Availability.BadLicense - - -class KnitroProblemContext: - """ - A wrapper around the KNITRO API for a single optimization problem. - - This class manages the lifecycle of a KNITRO problem instance (`kc`), - including building the problem by adding variables and constraints, - setting options, solving, and freeing the context. - """ - - var_map: MutableMapping[int, int] - con_map: MutableMapping[int, int] - obj_nl_expr: Optional[NLExpression] - con_nl_expr_map: MutableMapping[int, NLExpression] - - def __init__(self): - self._kc = KnitroLicenseManager.create_new_context() - self.var_map = {} - self.con_map = {} - self.obj_nl_expr = None - self.con_nl_expr_map = {} - - def __del__(self): - self.close() - - def _execute(self, api_fn, *args, **kwargs): - if self._kc is None: - raise RuntimeError("KNITRO context has been freed and cannot be used.") - return api_fn(self._kc, *args, **kwargs) - - def close(self): - if self._kc is not None: - self._execute(knitro.KN_free) - self._kc = None - - def add_vars(self, variables: Iterable[VarData]): - n_vars = len(variables) - idx_vars = self._execute(knitro.KN_add_vars, n_vars) - if idx_vars is None: - return - - for i, var in zip(idx_vars, variables): - self.var_map[id(var)] = i - - var_types, fxbnds, lobnds, upbnds = {}, {}, {}, {} - for var in variables: - i = self.var_map[id(var)] - if var.is_binary(): - var_types[i] = knitro.KN_VARTYPE_BINARY - elif var.is_integer(): - var_types[i] = knitro.KN_VARTYPE_INTEGER - elif not var.is_continuous(): - msg = f"Unknown variable type for variable {var.name}." - raise ValueError(msg) - - if var.fixed: - fxbnds[i] = value(var.value) - else: - if var.has_lb(): - lobnds[i] = value(var.lb) - if var.has_ub(): - upbnds[i] = value(var.ub) - - self._execute(knitro.KN_set_var_types, var_types.keys(), var_types.values()) - self._execute(knitro.KN_set_var_fxbnds, fxbnds.keys(), fxbnds.values()) - self._execute(knitro.KN_set_var_lobnds, lobnds.keys(), lobnds.values()) - self._execute(knitro.KN_set_var_upbnds, upbnds.keys(), upbnds.values()) - - def _add_expr_structs_from_repn( - self, - repn: StandardRepn, - add_const_fn: Callable[[float], None], - add_lin_fn: Callable[[Iterable[int], Iterable[float]], None], - add_quad_fn: Callable[[Iterable[int], Iterable[int], Iterable[float]], None], - ): - if repn.constant is not None: - add_const_fn(repn.constant) - if repn.linear_vars: - idx_lin_vars = [self.var_map.get(id(v)) for v in repn.linear_vars] - add_lin_fn(idx_lin_vars, list(repn.linear_coefs)) - if repn.quadratic_vars: - quad_vars1, quad_vars2 = zip(*repn.quadratic_vars) - idx_quad_vars1 = [self.var_map.get(id(v)) for v in quad_vars1] - idx_quad_vars2 = [self.var_map.get(id(v)) for v in quad_vars2] - add_quad_fn(idx_quad_vars1, idx_quad_vars2, list(repn.quadratic_coefs)) - - def add_cons(self, cons: Iterable[ConstraintData]): - n_cons = len(cons) - idx_cons = self._execute(knitro.KN_add_cons, n_cons) - if idx_cons is None: - return - - for i, con in zip(idx_cons, cons): - self.con_map[id(con)] = i - - eqbnds, lobnds, upbnds = {}, {}, {} - for con in cons: - i = self.con_map[id(con)] - if con.equality: - eqbnds[i] = value(con.lower) - else: - if con.has_lb(): - lobnds[i] = value(con.lb) - if con.has_ub(): - upbnds[i] = value(con.ub) - - self._execute(knitro.KN_set_con_eqbnds, eqbnds.keys(), eqbnds.values()) - self._execute(knitro.KN_set_con_lobnds, lobnds.keys(), lobnds.values()) - self._execute(knitro.KN_set_con_upbnds, upbnds.keys(), upbnds.values()) - - for con in cons: - i = self.con_map[id(con)] - repn = generate_standard_repn(con.body) - self._add_expr_structs_from_repn( - repn, - add_const_fn=partial(self._execute, knitro.KN_add_con_constants, i), - add_lin_fn=partial(self._execute, knitro.KN_add_con_linear_struct, i), - add_quad_fn=partial( - self._execute, knitro.KN_add_con_quadratic_struct, i - ), - ) - if repn.nonlinear_expr is not None: - self.con_nl_expr_map[i] = NLExpression( - repn.nonlinear_expr, repn.nonlinear_vars - ) - - def set_obj(self, obj: ObjectiveData): - obj_goal = ( - knitro.KN_OBJGOAL_MINIMIZE - if obj.is_minimizing() - else knitro.KN_OBJGOAL_MAXIMIZE - ) - self._execute(knitro.KN_set_obj_goal, obj_goal) - repn = generate_standard_repn(obj.expr) - self._add_expr_structs_from_repn( - repn, - add_const_fn=partial(self._execute, knitro.KN_add_obj_constant), - add_lin_fn=partial(self._execute, knitro.KN_add_obj_linear_struct), - add_quad_fn=partial(self._execute, knitro.KN_add_obj_quadratic_struct), - ) - if repn.nonlinear_expr is not None: - self.obj_nl_expr = NLExpression(repn.nonlinear_expr, repn.nonlinear_vars) - - def _build_callback(self): - if self.obj_nl_expr is None and not self.con_nl_expr_map: - return None - - obj_eval = ( - self.obj_nl_expr.create_evaluator(self.var_map) - if self.obj_nl_expr is not None - else None - ) - con_eval_map = { - i: nl_expr.create_evaluator(self.var_map) - for i, nl_expr in self.con_nl_expr_map.items() - } - - def _callback(_, cb, req, res, data=None): - if req.type != knitro.KN_RC_EVALFC: - # This callback only handles function evaluations, not derivatives. - return -1 - x = req.x - if obj_eval is not None: - res.obj = obj_eval(x) - for i, con_eval in enumerate(con_eval_map.values()): - res.c[i] = con_eval(x) - return 0 # Return 0 for success - - return _callback - - def _register_callback(self): - callback_fn = self._build_callback() - if callback_fn is not None: - eval_obj = self.obj_nl_expr is not None - idx_cons = list(self.con_nl_expr_map.keys()) - self._execute(knitro.KN_add_eval_callback, eval_obj, idx_cons, callback_fn) - - def solve(self) -> int: - self._register_callback() - return self._execute(knitro.KN_solve) - - def get_num_iters(self) -> int: - return self._execute(knitro.KN_get_number_iters) - - def get_solve_time(self) -> float: - return self._execute(knitro.KN_get_solve_time_real) - - def get_primals(self, variables: Iterable[VarData]) -> Optional[List[float]]: - idx_vars = [self.var_map.get(id(var)) for var in variables] - return self._execute(knitro.KN_get_var_primal_values, idx_vars) - - def get_duals(self, cons: Iterable[ConstraintData]) -> Optional[List[float]]: - idx_cons = [self.con_map.get(id(con)) for con in cons] - return self._execute(knitro.KN_get_con_dual_values, idx_cons) - - def get_obj_value(self) -> Optional[float]: - return self._execute(knitro.KN_get_obj_value) - - def set_options(self, **options): - for param, val in options.items(): - param_id = self._execute(knitro.KN_get_param_id, param) - param_type = self._execute(knitro.KN_get_param_type, param_id) - if param_type == knitro.KN_PARAMTYPE_INTEGER: - setter_fn = knitro.KN_set_int_param - elif param_type == knitro.KN_PARAMTYPE_FLOAT: - setter_fn = knitro.KN_set_double_param - else: - setter_fn = knitro.KN_set_char_param - self._execute(setter_fn, param_id, val) - - def set_outlev(self, level: int = knitro.KN_OUTLEV_ALL): - self.set_options(outlev=level) - - def set_time_limit(self, time_limit: float): - self.set_options(maxtime_cpu=time_limit) - - def set_num_threads(self, num_threads: int): - self.set_options(numthreads=num_threads) - - -class KnitroDirectSolutionLoader(SolutionLoaderBase): - def __init__(self, problem: KnitroProblemContext, model_repn: ModelRepresentation): +class _SolutionLoader(SolutionLoaderBase): + def __init__(self, engine: Engine, problem: ProblemData): super().__init__() + self._engine = engine self._problem = problem - self._model_repn = model_repn def get_number_of_solutions(self) -> int: - _, _, x, _ = self._problem._execute(knitro.KN_get_solution) - return 1 if x is not None else 0 + return self._engine.get_num_solutions() def get_vars( self, vars_to_load: Optional[Sequence[VarData]] = None ) -> Mapping[VarData, float]: if vars_to_load is None: - vars_to_load = self._model_repn.variables + vars_to_load = self._problem.variables - x = self._problem.get_primals(vars_to_load) + x = self._engine.get_primals(vars_to_load) if x is None: return NoSolutionError() return ComponentMap([(var, x[i]) for i, var in enumerate(vars_to_load)]) @@ -453,36 +68,29 @@ def get_duals( self, cons_to_load: Optional[Sequence[ConstraintData]] = None ) -> Mapping[ConstraintData, float]: if cons_to_load is None: - cons_to_load = self._model_repn.cons + cons_to_load = self._problem.cons - y = self._problem.get_duals(cons_to_load) + y = self._engine.get_duals(cons_to_load) if y is None: return NoDualsError() return ComponentMap([(con, y[i]) for i, con in enumerate(cons_to_load)]) -class KnitroDirectSolver(SolverBase): +class Solver(SolverMixin, SolverBase): NAME = "KNITRO" CONFIG = KnitroConfig() config: KnitroConfig def __init__(self, **kwds): - super().__init__(**kwds) - self._available_cache = NOTSET - - def available(self) -> Availability: - if self._available_cache is NOTSET: - self._available_cache = KnitroLicenseManager.available() - return self._available_cache - - def version(self): - return KnitroLicenseManager.version() + SolverMixin.__init__(self) + SolverBase.__init__(self, **kwds) + self._engine = Engine() def _build_config(self, **kwds) -> KnitroConfig: return self.config(value=kwds, preserve_implicit=True) - def _validate_model(self, model_repn: ModelRepresentation): - if len(model_repn.objs) > 1: + def _validate_problem(self, problem: ProblemData): + if len(problem.objs) > 1: raise IncompatibleModelError( f"{self.NAME} does not support multiple objectives." ) @@ -496,42 +104,42 @@ def solve(self, model: BlockData, **kwds) -> Results: raise ApplicationError(f"Solver {self.NAME} is not available: {avail}.") StaleFlagManager.mark_all_as_stale() - timer.start("build_model_representation") - model_repn = build_model_representation(model) - timer.stop("build_model_representation") + timer.start("build_problem") + problem = ProblemData(model) + timer.stop("build_problem") - self._validate_model(model_repn) + self._validate_problem(problem) stream = io.StringIO() ostreams = [stream] + config.tee with capture_output(TeeStream(*ostreams), capture_fd=False): - problem = KnitroProblemContext() + self._engine.renew() timer.start("add_vars") - problem.add_vars(model_repn.variables) + self._engine.add_vars(problem.variables) timer.stop("add_vars") timer.start("add_cons") - problem.add_cons(model_repn.cons) + self._engine.add_cons(problem.cons) timer.stop("add_cons") - if model_repn.objs: + if problem.objs: timer.start("set_objective") - problem.set_obj(model_repn.objs[0]) + self._engine.set_obj(problem.objs[0]) timer.stop("set_objective") - problem.set_outlev() + self._engine.set_outlev() if config.threads is not None: - problem.set_num_threads(config.threads) + self._engine.set_num_threads(config.threads) if config.time_limit is not None: - problem.set_time_limit(config.time_limit) + self._engine.set_time_limit(config.time_limit) timer.start("load_options") - problem.set_options(**config.solver_options) + self._engine.set_options(**config.solver_options) timer.stop("load_options") timer.start("solve") - status = problem.solve() + status = self._engine.solve() timer.stop("solve") results = Results() @@ -539,19 +147,18 @@ def solve(self, model: BlockData, **kwds) -> Results: results.solver_name = self.NAME results.solver_version = self.version() results.solver_log = stream.getvalue() - results.iteration_count = problem.get_num_iters() - results.solution_status = get_solution_status(status) - results.termination_condition = get_termination_condition(status) + results.iteration_count = self._engine.get_num_iters() + results.solution_status = self._engine.get_solution_status(status) + results.termination_condition = self._engine.get_termination_condition(status) + results.solution_loader = _SolutionLoader(self._engine, problem) if ( config.raise_exception_on_nonoptimal_result and results.termination_condition != TerminationCondition.convergenceCriteriaSatisfied ): raise NoOptimalSolutionError() - results.timing_info.solve_time = problem.get_solve_time() - results.incumbent_objective = problem.get_obj_value() - - results.solution_loader = KnitroDirectSolutionLoader(problem, model_repn) + results.timing_info.solve_time = self._engine.get_solve_time() + results.incumbent_objective = self._engine.get_obj_value() if config.load_solutions: timer.start("load_solutions") results.solution_loader.load_vars() diff --git a/pyomo/contrib/solver/solvers/knitro/engine.py b/pyomo/contrib/solver/solvers/knitro/engine.py new file mode 100644 index 00000000000..bb6bdfeb45c --- /dev/null +++ b/pyomo/contrib/solver/solvers/knitro/engine.py @@ -0,0 +1,308 @@ +# ___________________________________________________________________________ +# +# Pyomo: Python Optimization Modeling Objects +# Copyright (c) 2008-2025 +# National Technology and Engineering Solutions of Sandia, LLC +# Under the terms of Contract DE-NA0003525 with National Technology and +# Engineering Solutions of Sandia, LLC, the U.S. Government retains certain +# rights in this software. +# This software is distributed under the 3-clause BSD License. +# ___________________________________________________________________________ + +from collections.abc import Callable, Iterable, MutableMapping +from typing import List, Optional + +from pyomo.common.numeric_types import value +from pyomo.contrib.solver.common.results import SolutionStatus, TerminationCondition +from pyomo.core.base.constraint import ConstraintData +from pyomo.core.base.objective import ObjectiveData +from pyomo.core.base.var import VarData +from pyomo.core.plugins.transform.util import partial +from pyomo.repn.standard_repn import StandardRepn, generate_standard_repn + +from .api import knitro +from .mixin import License +from .utils import NonlinearExpressionData + + +class Engine: + """ + A wrapper around the KNITRO API for a single optimization problem. + + This class manages the lifecycle of a KNITRO problem instance (`kc`), + including building the problem by adding variables and constraints, + setting options, solving, and freeing the context. + """ + + var_map: MutableMapping[int, int] + con_map: MutableMapping[int, int] + obj_nl_expr: Optional[NonlinearExpressionData] + con_nl_expr_map: MutableMapping[int, NonlinearExpressionData] + + def __init__(self): + self._kc = None + self.var_map = {} + self.con_map = {} + self.obj_nl_expr = None + self.con_nl_expr_map = {} + + def __del__(self): + self.close() + + def renew(self): + self.close() + self._kc = License.create_context() + + def close(self): + if hasattr(self, "_kc") and self._kc is not None: + self._execute(knitro.KN_free) + self._kc = None + + def add_vars(self, variables: Iterable[VarData]): + n_vars = len(variables) + idx_vars = self._execute(knitro.KN_add_vars, n_vars) + if idx_vars is None: + return + + for i, var in zip(idx_vars, variables): + self.var_map[id(var)] = i + + var_types, fxbnds, lobnds, upbnds = {}, {}, {}, {} + for var in variables: + i = self.var_map[id(var)] + if var.is_binary(): + var_types[i] = knitro.KN_VARTYPE_BINARY + elif var.is_integer(): + var_types[i] = knitro.KN_VARTYPE_INTEGER + elif not var.is_continuous(): + msg = f"Unknown variable type for variable {var.name}." + raise ValueError(msg) + + if var.fixed: + fxbnds[i] = value(var.value) + else: + if var.has_lb(): + lobnds[i] = value(var.lb) + if var.has_ub(): + upbnds[i] = value(var.ub) + + self._execute(knitro.KN_set_var_types, var_types.keys(), var_types.values()) + self._execute(knitro.KN_set_var_fxbnds, fxbnds.keys(), fxbnds.values()) + self._execute(knitro.KN_set_var_lobnds, lobnds.keys(), lobnds.values()) + self._execute(knitro.KN_set_var_upbnds, upbnds.keys(), upbnds.values()) + + def add_cons(self, cons: Iterable[ConstraintData]): + n_cons = len(cons) + idx_cons = self._execute(knitro.KN_add_cons, n_cons) + if idx_cons is None: + return + + for i, con in zip(idx_cons, cons): + self.con_map[id(con)] = i + + eqbnds, lobnds, upbnds = {}, {}, {} + for con in cons: + i = self.con_map[id(con)] + if con.equality: + eqbnds[i] = value(con.lower) + else: + if con.has_lb(): + lobnds[i] = value(con.lower) + if con.has_ub(): + upbnds[i] = value(con.upper) + + self._execute(knitro.KN_set_con_eqbnds, eqbnds.keys(), eqbnds.values()) + self._execute(knitro.KN_set_con_lobnds, lobnds.keys(), lobnds.values()) + self._execute(knitro.KN_set_con_upbnds, upbnds.keys(), upbnds.values()) + + for con in cons: + i = self.con_map[id(con)] + repn = generate_standard_repn(con.body) + self._add_expr_structs_from_repn( + repn, + add_const_fn=partial(self._execute, knitro.KN_add_con_constants, i), + add_lin_fn=partial(self._execute, knitro.KN_add_con_linear_struct, i), + add_quad_fn=partial( + self._execute, knitro.KN_add_con_quadratic_struct, i + ), + ) + if repn.nonlinear_expr is not None: + self.con_nl_expr_map[i] = NonlinearExpressionData( + repn.nonlinear_expr, repn.nonlinear_vars + ) + + def set_obj(self, obj: ObjectiveData): + obj_goal = ( + knitro.KN_OBJGOAL_MINIMIZE + if obj.is_minimizing() + else knitro.KN_OBJGOAL_MAXIMIZE + ) + self._execute(knitro.KN_set_obj_goal, obj_goal) + repn = generate_standard_repn(obj.expr) + self._add_expr_structs_from_repn( + repn, + add_const_fn=partial(self._execute, knitro.KN_add_obj_constant), + add_lin_fn=partial(self._execute, knitro.KN_add_obj_linear_struct), + add_quad_fn=partial(self._execute, knitro.KN_add_obj_quadratic_struct), + ) + if repn.nonlinear_expr is not None: + self.obj_nl_expr = NonlinearExpressionData( + repn.nonlinear_expr, repn.nonlinear_vars + ) + + def solve(self) -> int: + self._register_callback() + return self._execute(knitro.KN_solve) + + def get_num_iters(self) -> int: + return self._execute(knitro.KN_get_number_iters) + + def get_num_solutions(self) -> int: + _, _, x, _ = self._execute(knitro.KN_get_solution) + return 1 if x is not None else 0 + + def get_solve_time(self) -> float: + return self._execute(knitro.KN_get_solve_time_real) + + def get_primals(self, variables: Iterable[VarData]) -> Optional[List[float]]: + idx_vars = [self.var_map[id(var)] for var in variables] + return self._execute(knitro.KN_get_var_primal_values, idx_vars) + + def get_duals(self, cons: Iterable[ConstraintData]) -> Optional[List[float]]: + idx_cons = [self.con_map[id(con)] for con in cons] + return self._execute(knitro.KN_get_con_dual_values, idx_cons) + + def get_obj_value(self) -> Optional[float]: + return self._execute(knitro.KN_get_obj_value) + + def set_options(self, **options): + for param, val in options.items(): + param_id = self._execute(knitro.KN_get_param_id, param) + param_type = self._execute(knitro.KN_get_param_type, param_id) + if param_type == knitro.KN_PARAMTYPE_INTEGER: + setter_fn = knitro.KN_set_int_param + elif param_type == knitro.KN_PARAMTYPE_FLOAT: + setter_fn = knitro.KN_set_double_param + else: + setter_fn = knitro.KN_set_char_param + self._execute(setter_fn, param_id, val) + + def set_outlev(self, level: int = knitro.KN_OUTLEV_ALL): + self.set_options(outlev=level) + + def set_time_limit(self, time_limit: float): + self.set_options(maxtime_cpu=time_limit) + + def set_num_threads(self, nthreads: int): + self.set_options(threads=nthreads) + + @staticmethod + def get_solution_status(status: int) -> SolutionStatus: + if ( + status == knitro.KN_RC_OPTIMAL + or status == knitro.KN_RC_OPTIMAL_OR_SATISFACTORY + or status == knitro.KN_RC_NEAR_OPT + ): + return SolutionStatus.optimal + elif status == knitro.KN_RC_FEAS_NO_IMPROVE: + return SolutionStatus.feasible + elif ( + status == knitro.KN_RC_INFEASIBLE + or status == knitro.KN_RC_INFEAS_CON_BOUNDS + or status == knitro.KN_RC_INFEAS_VAR_BOUNDS + or status == knitro.KN_RC_INFEAS_NO_IMPROVE + ): + return SolutionStatus.infeasible + else: + return SolutionStatus.noSolution + + @staticmethod + def get_termination_condition(status: int) -> TerminationCondition: + if ( + status == knitro.KN_RC_OPTIMAL + or status == knitro.KN_RC_OPTIMAL_OR_SATISFACTORY + or status == knitro.KN_RC_NEAR_OPT + ): + return TerminationCondition.convergenceCriteriaSatisfied + elif status == knitro.KN_RC_INFEAS_NO_IMPROVE: + return TerminationCondition.locallyInfeasible + elif status == knitro.KN_RC_INFEASIBLE: + return TerminationCondition.provenInfeasible + elif ( + status == knitro.KN_RC_UNBOUNDED_OR_INFEAS + or status == knitro.KN_RC_UNBOUNDED + ): + return TerminationCondition.infeasibleOrUnbounded + elif ( + status == knitro.KN_RC_ITER_LIMIT_FEAS + or status == knitro.KN_RC_ITER_LIMIT_INFEAS + ): + return TerminationCondition.iterationLimit + elif ( + status == knitro.KN_RC_TIME_LIMIT_FEAS + or status == knitro.KN_RC_TIME_LIMIT_INFEAS + ): + return TerminationCondition.maxTimeLimit + elif status == knitro.KN_RC_USER_TERMINATION: + return TerminationCondition.interrupted + else: + return TerminationCondition.unknown + + # ----------------- Private methods ------------------------- + + def _execute(self, api_fn, *args, **kwargs): + if self._kc is None: + msg = "KNITRO context has been freed or has not been initialized and cannot be used." + raise RuntimeError(msg) + return api_fn(self._kc, *args, **kwargs) + + def _add_expr_structs_from_repn( + self, + repn: StandardRepn, + add_const_fn: Callable[[float], None], + add_lin_fn: Callable[[Iterable[int], Iterable[float]], None], + add_quad_fn: Callable[[Iterable[int], Iterable[int], Iterable[float]], None], + ): + if repn.constant is not None: + add_const_fn(repn.constant) + if repn.linear_vars: + idx_lin_vars = [self.var_map.get(id(v)) for v in repn.linear_vars] + add_lin_fn(idx_lin_vars, list(repn.linear_coefs)) + if repn.quadratic_vars: + quad_vars1, quad_vars2 = zip(*repn.quadratic_vars) + idx_quad_vars1 = [self.var_map.get(id(v)) for v in quad_vars1] + idx_quad_vars2 = [self.var_map.get(id(v)) for v in quad_vars2] + add_quad_fn(idx_quad_vars1, idx_quad_vars2, list(repn.quadratic_coefs)) + + def _build_callback(self): + if self.obj_nl_expr is None and not self.con_nl_expr_map: + return None + + obj_eval = ( + self.obj_nl_expr.create_evaluator(self.var_map) + if self.obj_nl_expr is not None + else None + ) + con_eval_map = { + i: nl_expr.create_evaluator(self.var_map) + for i, nl_expr in self.con_nl_expr_map.items() + } + + def _callback(_, cb, req, res, data=None): + if req.type != knitro.KN_RC_EVALFC: + return -1 + x = req.x + if obj_eval is not None: + res.obj = obj_eval(x) + for i, con_eval in enumerate(con_eval_map.values()): + res.c[i] = con_eval(x) + return 0 + + return _callback + + def _register_callback(self): + callback_fn = self._build_callback() + if callback_fn is not None: + eval_obj = self.obj_nl_expr is not None + idx_cons = list(self.con_nl_expr_map.keys()) + self._execute(knitro.KN_add_eval_callback, eval_obj, idx_cons, callback_fn) diff --git a/pyomo/contrib/solver/solvers/knitro/mixin.py b/pyomo/contrib/solver/solvers/knitro/mixin.py new file mode 100644 index 00000000000..0d00fb6889c --- /dev/null +++ b/pyomo/contrib/solver/solvers/knitro/mixin.py @@ -0,0 +1,106 @@ +# ___________________________________________________________________________ +# +# Pyomo: Python Optimization Modeling Objects +# Copyright (c) 2008-2025 +# National Technology and Engineering Solutions of Sandia, LLC +# Under the terms of Contract DE-NA0003525 with National Technology and +# Engineering Solutions of Sandia, LLC, the U.S. Government retains certain +# rights in this software. +# This software is distributed under the 3-clause BSD License. +# ___________________________________________________________________________ + +import io +from typing import Tuple + +from pyomo.common.tee import TeeStream, capture_output +from pyomo.contrib.solver.common.base import Availability + +from .api import KNITRO_AVAILABLE, KNITRO_VERSION, knitro + + +class License: + """ + Manages the global KNITRO license context and provides utility methods for license handling. + + This class handles license initialization, release, context creation, version reporting, + and license availability checks for the KNITRO solver. + """ + + _license_context = None + + @staticmethod + def initialize_license(): + """ + Initialize the global KNITRO license context if not already initialized. + + Returns: + The KNITRO license context object. + """ + if License._license_context is None: + License._license_context = knitro.KN_checkout_license() + return License._license_context + + @staticmethod + def release_license(): + """ + Release the global KNITRO license context if it exists. + """ + if License._license_context is not None: + knitro.KN_release_license(License._license_context) + License._license_context = None + + @staticmethod + def create_context(): + """ + Create a new KNITRO context using the global license context. + + Returns: + The new KNITRO context object. + """ + lmc = License.initialize_license() + return knitro.KN_new_lm(lmc) + + @staticmethod + def get_version() -> Tuple[int, int, int]: + """ + Get the version of the KNITRO solver as a tuple. + + Returns: + Tuple[int, int, int]: The (major, minor, patch) version of KNITRO. + """ + return tuple(int(x) for x in KNITRO_VERSION.split(".")) + + @staticmethod + def check_availability() -> Availability: + """ + Check if the KNITRO solver and license are available. + + Returns: + Availability: The availability status (FullLicense, BadLicense, NotFound). + """ + if not KNITRO_AVAILABLE: + return Availability.NotFound + try: + stream = io.StringIO() + with capture_output(TeeStream(stream), capture_fd=1): + kc = License.create_context() + knitro.KN_free(kc) + # TODO: parse the stream to check the license type. + return Availability.FullLicense + except Exception: + return Availability.BadLicense + + +class SolverMixin: + _available_cache: Availability + + def __init__(self): + self._available_cache = None + + def available(self) -> Availability: + if self._available_cache is None: + self._available_cache = License.check_availability() + return self._available_cache + + def version(self): + return License.get_version() \ No newline at end of file diff --git a/pyomo/contrib/solver/solvers/knitro/utils.py b/pyomo/contrib/solver/solvers/knitro/utils.py new file mode 100644 index 00000000000..414c751ebb2 --- /dev/null +++ b/pyomo/contrib/solver/solvers/knitro/utils.py @@ -0,0 +1,125 @@ +# ___________________________________________________________________________ +# +# Pyomo: Python Optimization Modeling Objects +# Copyright (c) 2008-2025 +# National Technology and Engineering Solutions of Sandia, LLC +# Under the terms of Contract DE-NA0003525 with National Technology and +# Engineering Solutions of Sandia, LLC, the U.S. Government retains certain +# rights in this software. +# This software is distributed under the 3-clause BSD License. +# ___________________________________________________________________________ + + +from collections.abc import Iterable, Mapping +from typing import Any, List, Optional + +from pyomo.common.numeric_types import value +from pyomo.contrib.solver.common.util import collect_vars_and_named_exprs +from pyomo.core.base.block import BlockData +from pyomo.core.base.constraint import Constraint, ConstraintData +from pyomo.core.base.objective import Objective, ObjectiveData +from pyomo.core.base.var import VarData + + +def get_active_objectives(block: BlockData) -> List[ObjectiveData]: + """ + Retrieve all active ObjectiveData objects from a Pyomo Block. + + Args: + block (BlockData): The Pyomo block to search for objectives. + + Returns: + List[ObjectiveData]: A sorted list of all active objectives in the block. + """ + generator = block.component_data_objects( + Objective, descend_into=True, active=True, sort=True + ) + return list(generator) + + +def get_active_constraints(block: BlockData) -> List[ConstraintData]: + """ + Retrieve all active ConstraintData objects from a Pyomo Block. + + Args: + block (BlockData): The Pyomo block to search for constraints. + + Returns: + List[ConstraintData]: A sorted list of all active constraints in the block. + """ + generator = block.component_data_objects( + Constraint, descend_into=True, active=True, sort=True + ) + return list(generator) + + +class ProblemData: + """ + Intermediate representation of a Pyomo model for KNITRO. + + Collects all active objectives, constraints, and referenced variables from a Pyomo Block. + This class is used to extract and organize model data before passing it to the solver. + + Attributes: + objs (List[ObjectiveData]): List of active objectives. + cons (List[ConstraintData]): List of active constraints. + variables (List[VarData]): List of all referenced variables. + """ + + objs: List[ObjectiveData] + cons: List[ConstraintData] + variables: List[VarData] + + def __init__(self, block: BlockData): + self.objs = get_active_objectives(block) + self.cons = get_active_constraints(block) + + # Collect all referenced variables using a dictionary to ensure uniqueness. + var_map = {} + for obj in self.objs: + _, variables, _, _ = collect_vars_and_named_exprs(obj.expr) + for v in variables: + var_map[id(v)] = v + for con in self.cons: + _, variables, _, _ = collect_vars_and_named_exprs(con.body) + for v in variables: + var_map[id(v)] = v + self.variables = list(var_map.values()) + + +class NonlinearExpressionData: + """ + Holds the data required to evaluate a non-linear expression. + + Attributes: + body (Optional[Any]): The Pyomo expression representing the non-linear body. + variables (List[VarData]): List of variables referenced in the expression. + """ + + body: Optional[Any] + variables: List[VarData] + + def __init__(self, expr: Optional[Any], variables: Iterable[VarData]): + self.body = expr + self.variables = list(variables) + + def create_evaluator(self, vmap: Mapping[int, int]): + """ + Create a callable evaluator for the non-linear expression. + + Args: + vmap (Mapping[int, int]): A mapping from variable id to index in the solver's variable vector. + + Returns: + Callable[[List[float]], float]: A function that takes a list of variable values (x) + and returns the evaluated value of the expression. + """ + + def _fn(x: List[float]) -> float: + # Set the values of the Pyomo variables from the solver's vector `x` + for var in self.variables: + i = vmap[id(var)] + var.set_value(x[i]) + return value(self.body) + + return _fn diff --git a/pyomo/duality/tests/test_t1_result.lp b/pyomo/duality/tests/test_t1_result.lp new file mode 100644 index 00000000000..f5d9dc6aa73 --- /dev/null +++ b/pyomo/duality/tests/test_t1_result.lp @@ -0,0 +1,31 @@ +\* Source Pyomo model name=unknown *\ + +max +o: ++5.0 c1 ++3.0 c2 ++4.0 c3 + +s.t. + +c_u_x1_: ++4 c1 ++1 c2 +<= 6 + +c_u_x2_: ++2 c1 ++1 c2 ++1 c3 +<= 4 + +c_u_x3_: ++1 c1 ++1 c3 +<= 2 + +bounds + 0 <= c1 <= +inf + 0 <= c2 <= +inf + 0 <= c3 <= +inf +end diff --git a/pyomo/duality/tests/test_t5_result.lp b/pyomo/duality/tests/test_t5_result.lp new file mode 100644 index 00000000000..9e3f6d0b3bf --- /dev/null +++ b/pyomo/duality/tests/test_t5_result.lp @@ -0,0 +1,29 @@ +\* Source Pyomo model name=unknown *\ + +min +o: +-100.0 c1 +-100.0 c2 +-100.0 c3 +-100.0 c4 + +s.t. + +c_u_x1_: ++4.44 c1 ++4 c3 ++3 c4 +<= -3 + +c_u_x2_: ++6.67 c2 ++2.86 c3 ++6 c4 +<= -2.5 + +bounds + -inf <= c1 <= 0 + -inf <= c2 <= 0 + -inf <= c3 <= 0 + -inf <= c4 <= 0 +end From b73273b62c41f6ea226e891e7b4faadc31db2e49 Mon Sep 17 00:00:00 2001 From: eminyouskn Date: Mon, 25 Aug 2025 14:01:02 -0400 Subject: [PATCH 09/15] Remove obsolete LP test files for duality tests --- pyomo/duality/tests/test_t1_result.lp | 31 --------------------------- pyomo/duality/tests/test_t5_result.lp | 29 ------------------------- 2 files changed, 60 deletions(-) delete mode 100644 pyomo/duality/tests/test_t1_result.lp delete mode 100644 pyomo/duality/tests/test_t5_result.lp diff --git a/pyomo/duality/tests/test_t1_result.lp b/pyomo/duality/tests/test_t1_result.lp deleted file mode 100644 index f5d9dc6aa73..00000000000 --- a/pyomo/duality/tests/test_t1_result.lp +++ /dev/null @@ -1,31 +0,0 @@ -\* Source Pyomo model name=unknown *\ - -max -o: -+5.0 c1 -+3.0 c2 -+4.0 c3 - -s.t. - -c_u_x1_: -+4 c1 -+1 c2 -<= 6 - -c_u_x2_: -+2 c1 -+1 c2 -+1 c3 -<= 4 - -c_u_x3_: -+1 c1 -+1 c3 -<= 2 - -bounds - 0 <= c1 <= +inf - 0 <= c2 <= +inf - 0 <= c3 <= +inf -end diff --git a/pyomo/duality/tests/test_t5_result.lp b/pyomo/duality/tests/test_t5_result.lp deleted file mode 100644 index 9e3f6d0b3bf..00000000000 --- a/pyomo/duality/tests/test_t5_result.lp +++ /dev/null @@ -1,29 +0,0 @@ -\* Source Pyomo model name=unknown *\ - -min -o: --100.0 c1 --100.0 c2 --100.0 c3 --100.0 c4 - -s.t. - -c_u_x1_: -+4.44 c1 -+4 c3 -+3 c4 -<= -3 - -c_u_x2_: -+6.67 c2 -+2.86 c3 -+6 c4 -<= -2.5 - -bounds - -inf <= c1 <= 0 - -inf <= c2 <= 0 - -inf <= c3 <= 0 - -inf <= c4 <= 0 -end From bdc037b031cfce3dc0153ff32e6d6b94e15d0c97 Mon Sep 17 00:00:00 2001 From: eminyouskn Date: Mon, 25 Aug 2025 15:34:00 -0400 Subject: [PATCH 10/15] Refactor code. --- .../contrib/solver/solvers/knitro/__init__.py | 2 +- pyomo/contrib/solver/solvers/knitro/base.py | 116 +++++++++++ pyomo/contrib/solver/solvers/knitro/config.py | 2 +- pyomo/contrib/solver/solvers/knitro/direct.py | 184 +++++------------- pyomo/contrib/solver/solvers/knitro/engine.py | 4 +- .../solvers/knitro/{mixin.py => package.py} | 24 +-- .../solver/solvers/knitro/solution_loader.py | 56 ++++++ pyomo/contrib/solver/solvers/knitro/utils.py | 47 +++-- .../tests/solvers/test_knitro_direct.py | 5 +- 9 files changed, 276 insertions(+), 164 deletions(-) create mode 100644 pyomo/contrib/solver/solvers/knitro/base.py rename pyomo/contrib/solver/solvers/knitro/{mixin.py => package.py} (84%) create mode 100644 pyomo/contrib/solver/solvers/knitro/solution_loader.py diff --git a/pyomo/contrib/solver/solvers/knitro/__init__.py b/pyomo/contrib/solver/solvers/knitro/__init__.py index d6acd9d4f3c..e16a79fcf5a 100644 --- a/pyomo/contrib/solver/solvers/knitro/__init__.py +++ b/pyomo/contrib/solver/solvers/knitro/__init__.py @@ -11,7 +11,7 @@ from pyomo.contrib.solver.common.factory import SolverFactory -from .config import KnitroConfig +from .config import Config as KnitroConfig from .direct import Solver as KnitroDirectSolver __all__ = ["KnitroConfig", "KnitroDirectSolver"] diff --git a/pyomo/contrib/solver/solvers/knitro/base.py b/pyomo/contrib/solver/solvers/knitro/base.py new file mode 100644 index 00000000000..61e33602b84 --- /dev/null +++ b/pyomo/contrib/solver/solvers/knitro/base.py @@ -0,0 +1,116 @@ +# ___________________________________________________________________________ +# +# Pyomo: Python Optimization Modeling Objects +# Copyright (c) 2008-2025 +# National Technology and Engineering Solutions of Sandia, LLC +# Under the terms of Contract DE-NA0003525 with National Technology and +# Engineering Solutions of Sandia, LLC, the U.S. Government retains certain +# rights in this software. +# This software is distributed under the 3-clause BSD License. +# ___________________________________________________________________________ + + +import abc +from collections.abc import Iterable +from io import StringIO + +from pyomo.common.errors import ApplicationError +from pyomo.common.tee import TeeStream, capture_output +from pyomo.common.timing import HierarchicalTimer +from pyomo.contrib.solver.common import base +from pyomo.contrib.solver.common.results import Results +from pyomo.contrib.solver.common.util import IncompatibleModelError +from pyomo.core.base.block import BlockData +from pyomo.core.base.constraint import ConstraintData +from pyomo.core.base.var import VarData +from pyomo.core.staleflag import StaleFlagManager + +from .config import Config +from .engine import Engine +from .utils import Problem + + +class SolverBase(base.SolverBase): + CONFIG = Config() + config: Config + + _engine: Engine + _problem: Problem + + def __init__(self, **kwds): + super().__init__(**kwds) + self._engine = Engine() + self._problem = Problem() + + def solve(self, model: BlockData, **kwds) -> Results: + self._check_available() + + config = self._build_config(**kwds) + timer = config.timer or HierarchicalTimer() + + StaleFlagManager.mark_all_as_stale() + self._presolve(model, config, timer) + self._validate_problem() + + stream = StringIO() + ostreams = [stream] + config.tee + with capture_output(TeeStream(*ostreams), capture_fd=False): + status = self._solve(config, timer) + + return self._postsolve(stream, config, timer, status) + + def _build_config(self, **kwds) -> Config: + return self.config(value=kwds, preserve_implicit=True) + + def _validate_problem(self): + if len(self._problem.objs) > 1: + msg = f"{self.name} does not support multiple objectives." + raise IncompatibleModelError(msg) + + def _check_available(self): + avail = self.available() + if not avail: + msg = f"Solver {self.name} is not available: {avail}." + raise ApplicationError(msg) + + @abc.abstractmethod + def _presolve(self, model: BlockData, config: Config, timer: HierarchicalTimer): + raise NotImplementedError + + @abc.abstractmethod + def _solve(self, config: Config, timer: HierarchicalTimer) -> int: + raise NotImplementedError + + def _postsolve( + self, stream: StringIO, config: Config, timer: HierarchicalTimer, status: int + ) -> Results: + results = Results() + results.solver_name = self.name + results.solver_version = self.version() + results.solver_log = stream.getvalue() + results.solver_config = config + results.solution_status = self._engine.get_solution_status(status) + results.termination_condition = self._engine.get_termination_condition(status) + results.incumbent_objective = self._engine.get_obj_value() + results.iteration_count = self._engine.get_num_iters() + results.timing_info.timer = timer + results.timing_info.solve_time = self._engine.get_solve_time() + return results + + def get_vars(self): + return self._problem.variables + + def get_objs(self): + return self._problem.objs + + def get_cons(self): + return self._problem.cons + + def get_primals(self, vars_to_load: Iterable[VarData]): + return self._engine.get_primals(vars_to_load) + + def get_duals(self, cons_to_load: Iterable[ConstraintData]): + return self._engine.get_duals(cons_to_load) + + def get_num_solutions(self): + return self._engine.get_num_solutions() diff --git a/pyomo/contrib/solver/solvers/knitro/config.py b/pyomo/contrib/solver/solvers/knitro/config.py index e2e625ee919..a6c4bc46fdb 100644 --- a/pyomo/contrib/solver/solvers/knitro/config.py +++ b/pyomo/contrib/solver/solvers/knitro/config.py @@ -13,7 +13,7 @@ from pyomo.contrib.solver.common.config import SolverConfig -class KnitroConfig(SolverConfig): +class Config(SolverConfig): def __init__( self, description=None, diff --git a/pyomo/contrib/solver/solvers/knitro/direct.py b/pyomo/contrib/solver/solvers/knitro/direct.py index 0d474548f53..e3b66f68cce 100644 --- a/pyomo/contrib/solver/solvers/knitro/direct.py +++ b/pyomo/contrib/solver/solvers/knitro/direct.py @@ -10,155 +10,75 @@ # ___________________________________________________________________________ -import io -from collections.abc import Mapping, Sequence -from typing import Optional - -from pyomo.common.collections import ComponentMap -from pyomo.common.errors import ApplicationError -from pyomo.common.flags import NOTSET -from pyomo.common.tee import TeeStream, capture_output +from io import StringIO + from pyomo.common.timing import HierarchicalTimer -from pyomo.contrib.solver.common.base import Availability, SolverBase -from pyomo.contrib.solver.common.results import Results, TerminationCondition -from pyomo.contrib.solver.common.solution_loader import SolutionLoaderBase -from pyomo.contrib.solver.common.util import ( - IncompatibleModelError, - NoDualsError, - NoOptimalSolutionError, - NoSolutionError, -) +from pyomo.contrib.solver.common.results import TerminationCondition +from pyomo.contrib.solver.common.util import NoOptimalSolutionError from pyomo.core.base.block import BlockData -from pyomo.core.base.constraint import ConstraintData -from pyomo.core.base.var import VarData -from pyomo.core.staleflag import StaleFlagManager - -from .api import knitro -from .mixin import License, SolverMixin -from .config import KnitroConfig -from .engine import Engine -from .utils import ProblemData - - -class _SolutionLoader(SolutionLoaderBase): - def __init__(self, engine: Engine, problem: ProblemData): - super().__init__() - self._engine = engine - self._problem = problem - - def get_number_of_solutions(self) -> int: - return self._engine.get_num_solutions() - - def get_vars( - self, vars_to_load: Optional[Sequence[VarData]] = None - ) -> Mapping[VarData, float]: - if vars_to_load is None: - vars_to_load = self._problem.variables - - x = self._engine.get_primals(vars_to_load) - if x is None: - return NoSolutionError() - return ComponentMap([(var, x[i]) for i, var in enumerate(vars_to_load)]) - - # TODO: remove this when the solution loader is fixed. - def get_primals(self, vars_to_load=None): - return self.get_vars(vars_to_load) - - def get_duals( - self, cons_to_load: Optional[Sequence[ConstraintData]] = None - ) -> Mapping[ConstraintData, float]: - if cons_to_load is None: - cons_to_load = self._problem.cons - - y = self._engine.get_duals(cons_to_load) - if y is None: - return NoDualsError() - return ComponentMap([(con, y[i]) for i, con in enumerate(cons_to_load)]) - - -class Solver(SolverMixin, SolverBase): - NAME = "KNITRO" - CONFIG = KnitroConfig() - config: KnitroConfig + +from .base import SolverBase +from .config import Config +from .package import AvailabilityChecker +from .solution_loader import SolutionLoader + + +class Solver(AvailabilityChecker, SolverBase): def __init__(self, **kwds): - SolverMixin.__init__(self) + AvailabilityChecker.__init__(self) SolverBase.__init__(self, **kwds) - self._engine = Engine() - def _build_config(self, **kwds) -> KnitroConfig: - return self.config(value=kwds, preserve_implicit=True) + def _presolve(self, model: BlockData, config: Config, timer: HierarchicalTimer): + timer.start("build_problem") + self._problem.set_block(model) + timer.stop("build_problem") - def _validate_problem(self, problem: ProblemData): - if len(problem.objs) > 1: - raise IncompatibleModelError( - f"{self.NAME} does not support multiple objectives." - ) - def solve(self, model: BlockData, **kwds) -> Results: - config = self._build_config(**kwds) - timer = config.timer or HierarchicalTimer() + def _solve(self, config: Config, timer: HierarchicalTimer) -> int: + self._engine.renew() - avail = self.available() - if not avail: - raise ApplicationError(f"Solver {self.NAME} is not available: {avail}.") + timer.start("add_vars") + self._engine.add_vars(self._problem.variables) + timer.stop("add_vars") - StaleFlagManager.mark_all_as_stale() - timer.start("build_problem") - problem = ProblemData(model) - timer.stop("build_problem") + timer.start("add_cons") + self._engine.add_cons(self._problem.cons) + timer.stop("add_cons") + + if self._problem.objs: + timer.start("set_objective") + self._engine.set_obj(self._problem.objs[0]) + timer.stop("set_objective") + + self._engine.set_outlev() + if config.threads is not None: + self._engine.set_num_threads(config.threads) + if config.time_limit is not None: + self._engine.set_time_limit(config.time_limit) + + timer.start("load_options") + self._engine.set_options(**config.solver_options) + timer.stop("load_options") - self._validate_problem(problem) - - stream = io.StringIO() - ostreams = [stream] + config.tee - with capture_output(TeeStream(*ostreams), capture_fd=False): - self._engine.renew() - - timer.start("add_vars") - self._engine.add_vars(problem.variables) - timer.stop("add_vars") - - timer.start("add_cons") - self._engine.add_cons(problem.cons) - timer.stop("add_cons") - - if problem.objs: - timer.start("set_objective") - self._engine.set_obj(problem.objs[0]) - timer.stop("set_objective") - - self._engine.set_outlev() - if config.threads is not None: - self._engine.set_num_threads(config.threads) - if config.time_limit is not None: - self._engine.set_time_limit(config.time_limit) - - timer.start("load_options") - self._engine.set_options(**config.solver_options) - timer.stop("load_options") - - timer.start("solve") - status = self._engine.solve() - timer.stop("solve") - - results = Results() - results.solver_config = config - results.solver_name = self.NAME - results.solver_version = self.version() - results.solver_log = stream.getvalue() - results.iteration_count = self._engine.get_num_iters() - results.solution_status = self._engine.get_solution_status(status) - results.termination_condition = self._engine.get_termination_condition(status) - results.solution_loader = _SolutionLoader(self._engine, problem) + timer.start("solve") + status = self._engine.solve() + timer.stop("solve") + + return status + + def _postsolve( + self, stream: StringIO, config: Config, timer: HierarchicalTimer, status: int + ): + results = super()._postsolve(stream, config, timer, status) if ( config.raise_exception_on_nonoptimal_result and results.termination_condition != TerminationCondition.convergenceCriteriaSatisfied ): raise NoOptimalSolutionError() - results.timing_info.solve_time = self._engine.get_solve_time() - results.incumbent_objective = self._engine.get_obj_value() + + results.solution_loader = SolutionLoader(self) if config.load_solutions: timer.start("load_solutions") results.solution_loader.load_vars() diff --git a/pyomo/contrib/solver/solvers/knitro/engine.py b/pyomo/contrib/solver/solvers/knitro/engine.py index bb6bdfeb45c..daea3b85d72 100644 --- a/pyomo/contrib/solver/solvers/knitro/engine.py +++ b/pyomo/contrib/solver/solvers/knitro/engine.py @@ -21,7 +21,7 @@ from pyomo.repn.standard_repn import StandardRepn, generate_standard_repn from .api import knitro -from .mixin import License +from .package import Package from .utils import NonlinearExpressionData @@ -51,7 +51,7 @@ def __del__(self): def renew(self): self.close() - self._kc = License.create_context() + self._kc = Package.create_context() def close(self): if hasattr(self, "_kc") and self._kc is not None: diff --git a/pyomo/contrib/solver/solvers/knitro/mixin.py b/pyomo/contrib/solver/solvers/knitro/package.py similarity index 84% rename from pyomo/contrib/solver/solvers/knitro/mixin.py rename to pyomo/contrib/solver/solvers/knitro/package.py index 0d00fb6889c..7460949d69b 100644 --- a/pyomo/contrib/solver/solvers/knitro/mixin.py +++ b/pyomo/contrib/solver/solvers/knitro/package.py @@ -18,7 +18,7 @@ from .api import KNITRO_AVAILABLE, KNITRO_VERSION, knitro -class License: +class Package: """ Manages the global KNITRO license context and provides utility methods for license handling. @@ -36,18 +36,18 @@ def initialize_license(): Returns: The KNITRO license context object. """ - if License._license_context is None: - License._license_context = knitro.KN_checkout_license() - return License._license_context + if Package._license_context is None: + Package._license_context = knitro.KN_checkout_license() + return Package._license_context @staticmethod def release_license(): """ Release the global KNITRO license context if it exists. """ - if License._license_context is not None: - knitro.KN_release_license(License._license_context) - License._license_context = None + if Package._license_context is not None: + knitro.KN_release_license(Package._license_context) + Package._license_context = None @staticmethod def create_context(): @@ -57,7 +57,7 @@ def create_context(): Returns: The new KNITRO context object. """ - lmc = License.initialize_license() + lmc = Package.initialize_license() return knitro.KN_new_lm(lmc) @staticmethod @@ -83,7 +83,7 @@ def check_availability() -> Availability: try: stream = io.StringIO() with capture_output(TeeStream(stream), capture_fd=1): - kc = License.create_context() + kc = Package.create_context() knitro.KN_free(kc) # TODO: parse the stream to check the license type. return Availability.FullLicense @@ -91,7 +91,7 @@ def check_availability() -> Availability: return Availability.BadLicense -class SolverMixin: +class AvailabilityChecker: _available_cache: Availability def __init__(self): @@ -99,8 +99,8 @@ def __init__(self): def available(self) -> Availability: if self._available_cache is None: - self._available_cache = License.check_availability() + self._available_cache = Package.check_availability() return self._available_cache def version(self): - return License.get_version() \ No newline at end of file + return Package.get_version() diff --git a/pyomo/contrib/solver/solvers/knitro/solution_loader.py b/pyomo/contrib/solver/solvers/knitro/solution_loader.py new file mode 100644 index 00000000000..288e614b4f8 --- /dev/null +++ b/pyomo/contrib/solver/solvers/knitro/solution_loader.py @@ -0,0 +1,56 @@ +# ___________________________________________________________________________ +# +# Pyomo: Python Optimization Modeling Objects +# Copyright (c) 2008-2025 +# National Technology and Engineering Solutions of Sandia, LLC +# Under the terms of Contract DE-NA0003525 with National Technology and +# Engineering Solutions of Sandia, LLC, the U.S. Government retains certain +# rights in this software. +# This software is distributed under the 3-clause BSD License. +# ___________________________________________________________________________ + +from collections.abc import Mapping, Sequence +from typing import Optional + +from pyomo.common.collections import ComponentMap +from pyomo.contrib.solver.common.solution_loader import SolutionLoaderBase +from pyomo.contrib.solver.common.util import NoDualsError, NoSolutionError +from pyomo.core.base.constraint import ConstraintData +from pyomo.core.base.var import VarData + +from .base import SolverBase + + +class SolutionLoader(SolutionLoaderBase): + def __init__(self, solver: SolverBase): + super().__init__() + self._solver = solver + + def get_number_of_solutions(self) -> int: + return self._solver.get_num_solutions() + + def get_vars( + self, vars_to_load: Optional[Sequence[VarData]] = None + ) -> Mapping[VarData, float]: + if vars_to_load is None: + vars_to_load = self._solver.get_vars() + + x = self._solver.get_primals(vars_to_load) + if x is None: + return NoSolutionError() + return ComponentMap([(var, x[i]) for i, var in enumerate(vars_to_load)]) + + # TODO: remove this when the solution loader is fixed. + def get_primals(self, vars_to_load=None): + return self.get_vars(vars_to_load) + + def get_duals( + self, cons_to_load: Optional[Sequence[ConstraintData]] = None + ) -> Mapping[ConstraintData, float]: + if cons_to_load is None: + cons_to_load = self._solver.get_cons() + + y = self._solver.get_duals(cons_to_load) + if y is None: + return NoDualsError() + return ComponentMap([(con, y[i]) for i, con in enumerate(cons_to_load)]) diff --git a/pyomo/contrib/solver/solvers/knitro/utils.py b/pyomo/contrib/solver/solvers/knitro/utils.py index 414c751ebb2..3d11062fd6b 100644 --- a/pyomo/contrib/solver/solvers/knitro/utils.py +++ b/pyomo/contrib/solver/solvers/knitro/utils.py @@ -10,7 +10,7 @@ # ___________________________________________________________________________ -from collections.abc import Iterable, Mapping +from collections.abc import Iterable, Mapping, MutableMapping from typing import Any, List, Optional from pyomo.common.numeric_types import value @@ -53,7 +53,7 @@ def get_active_constraints(block: BlockData) -> List[ConstraintData]: return list(generator) -class ProblemData: +class Problem: """ Intermediate representation of a Pyomo model for KNITRO. @@ -69,22 +69,41 @@ class ProblemData: objs: List[ObjectiveData] cons: List[ConstraintData] variables: List[VarData] - - def __init__(self, block: BlockData): - self.objs = get_active_objectives(block) - self.cons = get_active_constraints(block) - - # Collect all referenced variables using a dictionary to ensure uniqueness. - var_map = {} - for obj in self.objs: + _var_map: MutableMapping[int, VarData] + + def __init__(self, block: Optional[BlockData] = None): + self._var_map = {} + self.objs = [] + self.cons = [] + self.variables = [] + if block is not None: + self.add_block(block) + + def clear(self): + self.objs.clear() + self.cons.clear() + self.variables.clear() + self._var_map.clear() + + def set_block(self, block: BlockData): + self.clear() + self.add_block(block) + + def add_block(self, block: BlockData): + new_objs = get_active_objectives(block) + new_cons = get_active_constraints(block) + self.objs.extend(new_objs) + self.cons.extend(new_cons) + + for obj in new_objs: _, variables, _, _ = collect_vars_and_named_exprs(obj.expr) for v in variables: - var_map[id(v)] = v - for con in self.cons: + self._var_map[id(v)] = v + for con in new_cons: _, variables, _, _ = collect_vars_and_named_exprs(con.body) for v in variables: - var_map[id(v)] = v - self.variables = list(var_map.values()) + self._var_map[id(v)] = v + self.variables.extend(self._var_map.values()) class NonlinearExpressionData: diff --git a/pyomo/contrib/solver/tests/solvers/test_knitro_direct.py b/pyomo/contrib/solver/tests/solvers/test_knitro_direct.py index 0c3d927c4d0..6eac257e3ae 100644 --- a/pyomo/contrib/solver/tests/solvers/test_knitro_direct.py +++ b/pyomo/contrib/solver/tests/solvers/test_knitro_direct.py @@ -50,11 +50,12 @@ def test_class_member_list(self): "api_version", "is_persistent", "name", - "NAME", "solve", "version", ] - method_list = [m for m in dir(opt) if not m.startswith("_")] + method_list = [ + m for m in dir(opt) if not m.startswith("_") and not m.startswith("get") + ] self.assertListEqual(sorted(method_list), sorted(expected_list)) def test_default_instantiation(self): From f1e42cbd47d05933a3f8563efb9bb1752797a8e7 Mon Sep 17 00:00:00 2001 From: eminyouskn Date: Mon, 25 Aug 2025 16:24:06 -0400 Subject: [PATCH 11/15] Refactor code. --- pyomo/contrib/solver/solvers/knitro/base.py | 104 +++++++++++++++--- pyomo/contrib/solver/solvers/knitro/direct.py | 26 +---- pyomo/contrib/solver/solvers/knitro/engine.py | 57 +--------- .../knitro/{solution_loader.py => loaders.py} | 0 .../contrib/solver/solvers/knitro/package.py | 2 +- 5 files changed, 97 insertions(+), 92 deletions(-) rename pyomo/contrib/solver/solvers/knitro/{solution_loader.py => loaders.py} (100%) diff --git a/pyomo/contrib/solver/solvers/knitro/base.py b/pyomo/contrib/solver/solvers/knitro/base.py index 61e33602b84..acb26f62b01 100644 --- a/pyomo/contrib/solver/solvers/knitro/base.py +++ b/pyomo/contrib/solver/solvers/knitro/base.py @@ -10,54 +10,71 @@ # ___________________________________________________________________________ -import abc +from abc import abstractmethod from collections.abc import Iterable +from datetime import datetime, timezone from io import StringIO from pyomo.common.errors import ApplicationError from pyomo.common.tee import TeeStream, capture_output from pyomo.common.timing import HierarchicalTimer from pyomo.contrib.solver.common import base -from pyomo.contrib.solver.common.results import Results +from pyomo.contrib.solver.common.results import ( + Results, + SolutionStatus, + TerminationCondition, +) from pyomo.contrib.solver.common.util import IncompatibleModelError from pyomo.core.base.block import BlockData from pyomo.core.base.constraint import ConstraintData from pyomo.core.base.var import VarData from pyomo.core.staleflag import StaleFlagManager +from .api import knitro from .config import Config from .engine import Engine +from .package import PackageChecker from .utils import Problem -class SolverBase(base.SolverBase): +class SolverBase(PackageChecker, base.SolverBase): CONFIG = Config() config: Config _engine: Engine _problem: Problem + _stream: StringIO def __init__(self, **kwds): - super().__init__(**kwds) + PackageChecker.__init__(self) + base.SolverBase.__init__(self, **kwds) self._engine = Engine() self._problem = Problem() + self._stream = StringIO() def solve(self, model: BlockData, **kwds) -> Results: + tick = datetime.now(timezone.utc) self._check_available() config = self._build_config(**kwds) timer = config.timer or HierarchicalTimer() StaleFlagManager.mark_all_as_stale() + self._presolve(model, config, timer) self._validate_problem() - stream = StringIO() - ostreams = [stream] + config.tee - with capture_output(TeeStream(*ostreams), capture_fd=False): - status = self._solve(config, timer) + self._stream = StringIO() + with capture_output(TeeStream(self._stream, *config.tee), capture_fd=False): + self._solve(config, timer) + + results = self._postsolve(config, timer) - return self._postsolve(stream, config, timer, status) + tock = datetime.now(timezone.utc) + + results.timing_info.start_timestamp = tick + results.timing_info.wall_time = (tock - tick).total_seconds() + return results def _build_config(self, **kwds) -> Config: return self.config(value=kwds, preserve_implicit=True) @@ -73,28 +90,27 @@ def _check_available(self): msg = f"Solver {self.name} is not available: {avail}." raise ApplicationError(msg) - @abc.abstractmethod + @abstractmethod def _presolve(self, model: BlockData, config: Config, timer: HierarchicalTimer): raise NotImplementedError - @abc.abstractmethod + @abstractmethod def _solve(self, config: Config, timer: HierarchicalTimer) -> int: raise NotImplementedError - def _postsolve( - self, stream: StringIO, config: Config, timer: HierarchicalTimer, status: int - ) -> Results: + def _postsolve(self, config: Config, timer: HierarchicalTimer) -> Results: + status = self._engine.get_status() results = Results() results.solver_name = self.name results.solver_version = self.version() - results.solver_log = stream.getvalue() + results.solver_log = self._stream.getvalue() results.solver_config = config - results.solution_status = self._engine.get_solution_status(status) - results.termination_condition = self._engine.get_termination_condition(status) + results.solution_status = self.get_solution_status(status) + results.termination_condition = self.get_termination_condition(status) results.incumbent_objective = self._engine.get_obj_value() results.iteration_count = self._engine.get_num_iters() - results.timing_info.timer = timer results.timing_info.solve_time = self._engine.get_solve_time() + results.timing_info.timer = timer return results def get_vars(self): @@ -114,3 +130,55 @@ def get_duals(self, cons_to_load: Iterable[ConstraintData]): def get_num_solutions(self): return self._engine.get_num_solutions() + + @staticmethod + def get_solution_status(status: int) -> SolutionStatus: + if ( + status == knitro.KN_RC_OPTIMAL + or status == knitro.KN_RC_OPTIMAL_OR_SATISFACTORY + or status == knitro.KN_RC_NEAR_OPT + ): + return SolutionStatus.optimal + elif status == knitro.KN_RC_FEAS_NO_IMPROVE: + return SolutionStatus.feasible + elif ( + status == knitro.KN_RC_INFEASIBLE + or status == knitro.KN_RC_INFEAS_CON_BOUNDS + or status == knitro.KN_RC_INFEAS_VAR_BOUNDS + or status == knitro.KN_RC_INFEAS_NO_IMPROVE + ): + return SolutionStatus.infeasible + else: + return SolutionStatus.noSolution + + @staticmethod + def get_termination_condition(status: int) -> TerminationCondition: + if ( + status == knitro.KN_RC_OPTIMAL + or status == knitro.KN_RC_OPTIMAL_OR_SATISFACTORY + or status == knitro.KN_RC_NEAR_OPT + ): + return TerminationCondition.convergenceCriteriaSatisfied + elif status == knitro.KN_RC_INFEAS_NO_IMPROVE: + return TerminationCondition.locallyInfeasible + elif status == knitro.KN_RC_INFEASIBLE: + return TerminationCondition.provenInfeasible + elif ( + status == knitro.KN_RC_UNBOUNDED_OR_INFEAS + or status == knitro.KN_RC_UNBOUNDED + ): + return TerminationCondition.infeasibleOrUnbounded + elif ( + status == knitro.KN_RC_ITER_LIMIT_FEAS + or status == knitro.KN_RC_ITER_LIMIT_INFEAS + ): + return TerminationCondition.iterationLimit + elif ( + status == knitro.KN_RC_TIME_LIMIT_FEAS + or status == knitro.KN_RC_TIME_LIMIT_INFEAS + ): + return TerminationCondition.maxTimeLimit + elif status == knitro.KN_RC_USER_TERMINATION: + return TerminationCondition.interrupted + else: + return TerminationCondition.unknown diff --git a/pyomo/contrib/solver/solvers/knitro/direct.py b/pyomo/contrib/solver/solvers/knitro/direct.py index e3b66f68cce..ce135df8836 100644 --- a/pyomo/contrib/solver/solvers/knitro/direct.py +++ b/pyomo/contrib/solver/solvers/knitro/direct.py @@ -10,8 +10,6 @@ # ___________________________________________________________________________ -from io import StringIO - from pyomo.common.timing import HierarchicalTimer from pyomo.contrib.solver.common.results import TerminationCondition from pyomo.contrib.solver.common.util import NoOptimalSolutionError @@ -19,23 +17,16 @@ from .base import SolverBase from .config import Config -from .package import AvailabilityChecker -from .solution_loader import SolutionLoader - - -class Solver(AvailabilityChecker, SolverBase): +from .loaders import SolutionLoader - def __init__(self, **kwds): - AvailabilityChecker.__init__(self) - SolverBase.__init__(self, **kwds) +class Solver(SolverBase): def _presolve(self, model: BlockData, config: Config, timer: HierarchicalTimer): timer.start("build_problem") self._problem.set_block(model) timer.stop("build_problem") - - def _solve(self, config: Config, timer: HierarchicalTimer) -> int: + def _solve(self, config: Config, timer: HierarchicalTimer) -> None: self._engine.renew() timer.start("add_vars") @@ -62,15 +53,11 @@ def _solve(self, config: Config, timer: HierarchicalTimer) -> int: timer.stop("load_options") timer.start("solve") - status = self._engine.solve() + self._engine.solve() timer.stop("solve") - return status - - def _postsolve( - self, stream: StringIO, config: Config, timer: HierarchicalTimer, status: int - ): - results = super()._postsolve(stream, config, timer, status) + def _postsolve(self, config: Config, timer: HierarchicalTimer): + results = super()._postsolve(config, timer) if ( config.raise_exception_on_nonoptimal_result and results.termination_condition @@ -83,5 +70,4 @@ def _postsolve( timer.start("load_solutions") results.solution_loader.load_vars() timer.stop("load_solutions") - return results diff --git a/pyomo/contrib/solver/solvers/knitro/engine.py b/pyomo/contrib/solver/solvers/knitro/engine.py index daea3b85d72..9aaf6255555 100644 --- a/pyomo/contrib/solver/solvers/knitro/engine.py +++ b/pyomo/contrib/solver/solvers/knitro/engine.py @@ -13,7 +13,6 @@ from typing import List, Optional from pyomo.common.numeric_types import value -from pyomo.contrib.solver.common.results import SolutionStatus, TerminationCondition from pyomo.core.base.constraint import ConstraintData from pyomo.core.base.objective import ObjectiveData from pyomo.core.base.var import VarData @@ -154,6 +153,10 @@ def solve(self) -> int: self._register_callback() return self._execute(knitro.KN_solve) + def get_status(self) -> int: + status, _, _, _ = self._execute(knitro.KN_get_solution) + return status + def get_num_iters(self) -> int: return self._execute(knitro.KN_get_number_iters) @@ -196,58 +199,6 @@ def set_time_limit(self, time_limit: float): def set_num_threads(self, nthreads: int): self.set_options(threads=nthreads) - @staticmethod - def get_solution_status(status: int) -> SolutionStatus: - if ( - status == knitro.KN_RC_OPTIMAL - or status == knitro.KN_RC_OPTIMAL_OR_SATISFACTORY - or status == knitro.KN_RC_NEAR_OPT - ): - return SolutionStatus.optimal - elif status == knitro.KN_RC_FEAS_NO_IMPROVE: - return SolutionStatus.feasible - elif ( - status == knitro.KN_RC_INFEASIBLE - or status == knitro.KN_RC_INFEAS_CON_BOUNDS - or status == knitro.KN_RC_INFEAS_VAR_BOUNDS - or status == knitro.KN_RC_INFEAS_NO_IMPROVE - ): - return SolutionStatus.infeasible - else: - return SolutionStatus.noSolution - - @staticmethod - def get_termination_condition(status: int) -> TerminationCondition: - if ( - status == knitro.KN_RC_OPTIMAL - or status == knitro.KN_RC_OPTIMAL_OR_SATISFACTORY - or status == knitro.KN_RC_NEAR_OPT - ): - return TerminationCondition.convergenceCriteriaSatisfied - elif status == knitro.KN_RC_INFEAS_NO_IMPROVE: - return TerminationCondition.locallyInfeasible - elif status == knitro.KN_RC_INFEASIBLE: - return TerminationCondition.provenInfeasible - elif ( - status == knitro.KN_RC_UNBOUNDED_OR_INFEAS - or status == knitro.KN_RC_UNBOUNDED - ): - return TerminationCondition.infeasibleOrUnbounded - elif ( - status == knitro.KN_RC_ITER_LIMIT_FEAS - or status == knitro.KN_RC_ITER_LIMIT_INFEAS - ): - return TerminationCondition.iterationLimit - elif ( - status == knitro.KN_RC_TIME_LIMIT_FEAS - or status == knitro.KN_RC_TIME_LIMIT_INFEAS - ): - return TerminationCondition.maxTimeLimit - elif status == knitro.KN_RC_USER_TERMINATION: - return TerminationCondition.interrupted - else: - return TerminationCondition.unknown - # ----------------- Private methods ------------------------- def _execute(self, api_fn, *args, **kwargs): diff --git a/pyomo/contrib/solver/solvers/knitro/solution_loader.py b/pyomo/contrib/solver/solvers/knitro/loaders.py similarity index 100% rename from pyomo/contrib/solver/solvers/knitro/solution_loader.py rename to pyomo/contrib/solver/solvers/knitro/loaders.py diff --git a/pyomo/contrib/solver/solvers/knitro/package.py b/pyomo/contrib/solver/solvers/knitro/package.py index 7460949d69b..2feebd204cf 100644 --- a/pyomo/contrib/solver/solvers/knitro/package.py +++ b/pyomo/contrib/solver/solvers/knitro/package.py @@ -91,7 +91,7 @@ def check_availability() -> Availability: return Availability.BadLicense -class AvailabilityChecker: +class PackageChecker: _available_cache: Availability def __init__(self): From 100cbd6c31745703a995a40694e95296cb090ef2 Mon Sep 17 00:00:00 2001 From: eminyouskn Date: Mon, 25 Aug 2025 16:54:32 -0400 Subject: [PATCH 12/15] Refactor code. --- pyomo/contrib/solver/solvers/knitro/base.py | 61 ++++++++++++++----- pyomo/contrib/solver/solvers/knitro/direct.py | 18 +----- .../knitro/{loaders.py => solution.py} | 36 +++++------ 3 files changed, 63 insertions(+), 52 deletions(-) rename pyomo/contrib/solver/solvers/knitro/{loaders.py => solution.py} (59%) diff --git a/pyomo/contrib/solver/solvers/knitro/base.py b/pyomo/contrib/solver/solvers/knitro/base.py index acb26f62b01..e6441d29a37 100644 --- a/pyomo/contrib/solver/solvers/knitro/base.py +++ b/pyomo/contrib/solver/solvers/knitro/base.py @@ -11,10 +11,12 @@ from abc import abstractmethod -from collections.abc import Iterable +from collections.abc import Iterable, Sequence from datetime import datetime, timezone from io import StringIO +from typing import Optional +from pyomo.common.collections import ComponentMap from pyomo.common.errors import ApplicationError from pyomo.common.tee import TeeStream, capture_output from pyomo.common.timing import HierarchicalTimer @@ -24,7 +26,12 @@ SolutionStatus, TerminationCondition, ) -from pyomo.contrib.solver.common.util import IncompatibleModelError +from pyomo.contrib.solver.common.util import ( + IncompatibleModelError, + NoDualsError, + NoOptimalSolutionError, + NoSolutionError, +) from pyomo.core.base.block import BlockData from pyomo.core.base.constraint import ConstraintData from pyomo.core.base.var import VarData @@ -35,9 +42,10 @@ from .engine import Engine from .package import PackageChecker from .utils import Problem +from .solution import SolutionLoader, SolutionProvider -class SolverBase(PackageChecker, base.SolverBase): +class SolverBase(SolutionProvider, PackageChecker, base.SolverBase): CONFIG = Config() config: Config @@ -45,7 +53,7 @@ class SolverBase(PackageChecker, base.SolverBase): _problem: Problem _stream: StringIO - def __init__(self, **kwds): + def __init__(self, **kwds) -> None: PackageChecker.__init__(self) base.SolverBase.__init__(self, **kwds) self._engine = Engine() @@ -79,23 +87,23 @@ def solve(self, model: BlockData, **kwds) -> Results: def _build_config(self, **kwds) -> Config: return self.config(value=kwds, preserve_implicit=True) - def _validate_problem(self): + def _validate_problem(self) -> None: if len(self._problem.objs) > 1: msg = f"{self.name} does not support multiple objectives." raise IncompatibleModelError(msg) - def _check_available(self): + def _check_available(self) -> None: avail = self.available() if not avail: msg = f"Solver {self.name} is not available: {avail}." raise ApplicationError(msg) @abstractmethod - def _presolve(self, model: BlockData, config: Config, timer: HierarchicalTimer): + def _presolve(self, model: BlockData, config: Config, timer: HierarchicalTimer) -> None: raise NotImplementedError @abstractmethod - def _solve(self, config: Config, timer: HierarchicalTimer) -> int: + def _solve(self, config: Config, timer: HierarchicalTimer) -> None: raise NotImplementedError def _postsolve(self, config: Config, timer: HierarchicalTimer) -> Results: @@ -111,22 +119,47 @@ def _postsolve(self, config: Config, timer: HierarchicalTimer) -> Results: results.iteration_count = self._engine.get_num_iters() results.timing_info.solve_time = self._engine.get_solve_time() results.timing_info.timer = timer + + if ( + config.raise_exception_on_nonoptimal_result + and results.termination_condition + != TerminationCondition.convergenceCriteriaSatisfied + ): + raise NoOptimalSolutionError() + + results.solution_loader = SolutionLoader(self) + if config.load_solutions: + timer.start("load_solutions") + results.solution_loader.load_vars() + timer.stop("load_solutions") + return results def get_vars(self): return self._problem.variables - def get_objs(self): + def get_objectives(self): return self._problem.objs def get_cons(self): return self._problem.cons - def get_primals(self, vars_to_load: Iterable[VarData]): - return self._engine.get_primals(vars_to_load) - - def get_duals(self, cons_to_load: Iterable[ConstraintData]): - return self._engine.get_duals(cons_to_load) + def get_primals(self, vars_to_load: Optional[Sequence[VarData]] = None): + if vars_to_load is None: + vars_to_load = self.get_vars() + + x = self._engine.get_primals(vars_to_load) + if x is None: + return NoSolutionError() + return ComponentMap([(var, x[i]) for i, var in enumerate(vars_to_load)]) + + def get_duals(self, cons_to_load: Optional[Sequence[ConstraintData]] = None): + if cons_to_load is None: + cons_to_load = self.get_cons() + y = self._engine.get_duals(cons_to_load) + if y is None: + return NoDualsError() + return ComponentMap([(con, y[i]) for i, con in enumerate(cons_to_load)]) def get_num_solutions(self): return self._engine.get_num_solutions() diff --git a/pyomo/contrib/solver/solvers/knitro/direct.py b/pyomo/contrib/solver/solvers/knitro/direct.py index ce135df8836..53391e8349a 100644 --- a/pyomo/contrib/solver/solvers/knitro/direct.py +++ b/pyomo/contrib/solver/solvers/knitro/direct.py @@ -17,7 +17,7 @@ from .base import SolverBase from .config import Config -from .loaders import SolutionLoader +from .solution import SolutionLoader class Solver(SolverBase): @@ -55,19 +55,3 @@ def _solve(self, config: Config, timer: HierarchicalTimer) -> None: timer.start("solve") self._engine.solve() timer.stop("solve") - - def _postsolve(self, config: Config, timer: HierarchicalTimer): - results = super()._postsolve(config, timer) - if ( - config.raise_exception_on_nonoptimal_result - and results.termination_condition - != TerminationCondition.convergenceCriteriaSatisfied - ): - raise NoOptimalSolutionError() - - results.solution_loader = SolutionLoader(self) - if config.load_solutions: - timer.start("load_solutions") - results.solution_loader.load_vars() - timer.stop("load_solutions") - return results diff --git a/pyomo/contrib/solver/solvers/knitro/loaders.py b/pyomo/contrib/solver/solvers/knitro/solution.py similarity index 59% rename from pyomo/contrib/solver/solvers/knitro/loaders.py rename to pyomo/contrib/solver/solvers/knitro/solution.py index 288e614b4f8..245aa6f39e0 100644 --- a/pyomo/contrib/solver/solvers/knitro/loaders.py +++ b/pyomo/contrib/solver/solvers/knitro/solution.py @@ -10,35 +10,35 @@ # ___________________________________________________________________________ from collections.abc import Mapping, Sequence -from typing import Optional +from typing import Optional, Protocol -from pyomo.common.collections import ComponentMap from pyomo.contrib.solver.common.solution_loader import SolutionLoaderBase -from pyomo.contrib.solver.common.util import NoDualsError, NoSolutionError from pyomo.core.base.constraint import ConstraintData from pyomo.core.base.var import VarData -from .base import SolverBase + +class SolutionProvider(Protocol): + def get_num_solutions(self) -> int: ... + def get_primals( + self, vars_to_load: Optional[Sequence[VarData]] = None + ) -> Mapping[VarData, float]: ... + def get_duals( + self, cons_to_load: Optional[Sequence[ConstraintData]] = None + ) -> Mapping[ConstraintData, float]: ... class SolutionLoader(SolutionLoaderBase): - def __init__(self, solver: SolverBase): + def __init__(self, provider: SolutionProvider): super().__init__() - self._solver = solver + self._provider = provider def get_number_of_solutions(self) -> int: - return self._solver.get_num_solutions() + return self._provider.get_num_solutions() def get_vars( self, vars_to_load: Optional[Sequence[VarData]] = None ) -> Mapping[VarData, float]: - if vars_to_load is None: - vars_to_load = self._solver.get_vars() - - x = self._solver.get_primals(vars_to_load) - if x is None: - return NoSolutionError() - return ComponentMap([(var, x[i]) for i, var in enumerate(vars_to_load)]) + return self._provider.get_primals(vars_to_load) # TODO: remove this when the solution loader is fixed. def get_primals(self, vars_to_load=None): @@ -47,10 +47,4 @@ def get_primals(self, vars_to_load=None): def get_duals( self, cons_to_load: Optional[Sequence[ConstraintData]] = None ) -> Mapping[ConstraintData, float]: - if cons_to_load is None: - cons_to_load = self._solver.get_cons() - - y = self._solver.get_duals(cons_to_load) - if y is None: - return NoDualsError() - return ComponentMap([(con, y[i]) for i, con in enumerate(cons_to_load)]) + return self._provider.get_duals(cons_to_load) From 8fd687ce2a246cb3e69193dac134c83a30c0bb2c Mon Sep 17 00:00:00 2001 From: eminyouskn Date: Tue, 26 Aug 2025 16:07:20 -0400 Subject: [PATCH 13/15] Add gradient for nonlinear. --- pyomo/contrib/solver/solvers/knitro/api.py | 4 +- pyomo/contrib/solver/solvers/knitro/base.py | 22 +++++- pyomo/contrib/solver/solvers/knitro/config.py | 9 +++ pyomo/contrib/solver/solvers/knitro/engine.py | 67 ++++++++++++++++--- pyomo/contrib/solver/solvers/knitro/utils.py | 45 ++++++++++++- 5 files changed, 133 insertions(+), 14 deletions(-) diff --git a/pyomo/contrib/solver/solvers/knitro/api.py b/pyomo/contrib/solver/solvers/knitro/api.py index a3f7d7eae9d..e24e7abe966 100644 --- a/pyomo/contrib/solver/solvers/knitro/api.py +++ b/pyomo/contrib/solver/solvers/knitro/api.py @@ -12,7 +12,7 @@ from pyomo.common.dependencies import attempt_import -# import knitro +import knitro -knitro, KNITRO_AVAILABLE = attempt_import("knitro") +_, KNITRO_AVAILABLE = attempt_import("knitro") KNITRO_VERSION = knitro.__version__ if KNITRO_AVAILABLE else "0.0.0" diff --git a/pyomo/contrib/solver/solvers/knitro/base.py b/pyomo/contrib/solver/solvers/knitro/base.py index e6441d29a37..ed408c30c04 100644 --- a/pyomo/contrib/solver/solvers/knitro/base.py +++ b/pyomo/contrib/solver/solvers/knitro/base.py @@ -11,13 +11,14 @@ from abc import abstractmethod -from collections.abc import Iterable, Sequence +from collections.abc import Iterable, Mapping, Sequence from datetime import datetime, timezone from io import StringIO from typing import Optional from pyomo.common.collections import ComponentMap from pyomo.common.errors import ApplicationError +from pyomo.common.numeric_types import value from pyomo.common.tee import TeeStream, capture_output from pyomo.common.timing import HierarchicalTimer from pyomo.contrib.solver.common import base @@ -52,6 +53,7 @@ class SolverBase(SolutionProvider, PackageChecker, base.SolverBase): _engine: Engine _problem: Problem _stream: StringIO + _saved_var_values: Mapping[int, float] def __init__(self, **kwds) -> None: PackageChecker.__init__(self) @@ -73,9 +75,15 @@ def solve(self, model: BlockData, **kwds) -> Results: self._validate_problem() self._stream = StringIO() + if config.restore_variable_values_after_solve: + self._save_var_values() + with capture_output(TeeStream(self._stream, *config.tee), capture_fd=False): self._solve(config, timer) + if config.restore_variable_values_after_solve: + self._restore_var_values() + results = self._postsolve(config, timer) tock = datetime.now(timezone.utc) @@ -98,8 +106,18 @@ def _check_available(self) -> None: msg = f"Solver {self.name} is not available: {avail}." raise ApplicationError(msg) + def _save_var_values(self) -> None: + self._saved_var_values = {id(var): value(var.value) for var in self.get_vars()} + + def _restore_var_values(self) -> None: + for var in self.get_vars(): + if id(var) in self._saved_var_values: + var.set_value(self._saved_var_values[id(var)]) + @abstractmethod - def _presolve(self, model: BlockData, config: Config, timer: HierarchicalTimer) -> None: + def _presolve( + self, model: BlockData, config: Config, timer: HierarchicalTimer + ) -> None: raise NotImplementedError @abstractmethod diff --git a/pyomo/contrib/solver/solvers/knitro/config.py b/pyomo/contrib/solver/solvers/knitro/config.py index a6c4bc46fdb..217ad82117d 100644 --- a/pyomo/contrib/solver/solvers/knitro/config.py +++ b/pyomo/contrib/solver/solvers/knitro/config.py @@ -38,3 +38,12 @@ def __init__( doc="KNITRO solver does not allow variable removal. We can either make the variable a continuous free variable or rebuild the whole model when variable removal is attempted. When `rebuild_model_on_remove_var` is set to True, the model will be rebuilt.", ), ) + + self.restore_variable_values_after_solve: bool = self.declare( + "restore_variable_values_after_solve", + ConfigValue( + domain=Bool, + default=True, + doc="To evaluate non-linear constraints, KNITRO solver sets explicit values on variables. This option controls whether to restore the original variable values after solving.", + ), + ) diff --git a/pyomo/contrib/solver/solvers/knitro/engine.py b/pyomo/contrib/solver/solvers/knitro/engine.py index 9aaf6255555..5c54a74ce55 100644 --- a/pyomo/contrib/solver/solvers/knitro/engine.py +++ b/pyomo/contrib/solver/solvers/knitro/engine.py @@ -226,20 +226,21 @@ def _add_expr_structs_from_repn( add_quad_fn(idx_quad_vars1, idx_quad_vars2, list(repn.quadratic_coefs)) def _build_callback(self): - if self.obj_nl_expr is None and not self.con_nl_expr_map: - return None - obj_eval = ( self.obj_nl_expr.create_evaluator(self.var_map) if self.obj_nl_expr is not None else None ) + con_eval_map = { i: nl_expr.create_evaluator(self.var_map) for i, nl_expr in self.con_nl_expr_map.items() } - def _callback(_, cb, req, res, data=None): + if obj_eval is not None and not con_eval_map: + return None, None + + def _callback_eval(_, cb, req, res, data=None): if req.type != knitro.KN_RC_EVALFC: return -1 x = req.x @@ -249,11 +250,61 @@ def _callback(_, cb, req, res, data=None): res.c[i] = con_eval(x) return 0 - return _callback + obj_grad = ( + self.obj_nl_expr.create_gradient_evaluator(self.var_map) + if self.obj_nl_expr is not None and self.obj_nl_expr.grad is not None + else None + ) + con_grad_map = { + i: nl_expr.create_gradient_evaluator(self.var_map) + for i, nl_expr in self.con_nl_expr_map.items() + if nl_expr.grad is not None + } + + if obj_grad is None and not con_grad_map: + return _callback_eval, None + + def _callback_grad(_, cb, req, res, data=None): + if req.type != knitro.KN_RC_EVALGA: + return -1 + x = req.x + if obj_grad is not None: + obj_g = obj_grad(x) + for j, g in enumerate(obj_g): + res.objGrad[j] = g + k = 0 + for con_grad in con_grad_map.values(): + con_g = con_grad(x) + for g in con_g: + res.jac[k] = g + k += 1 + return 0 + + return _callback_eval, _callback_grad def _register_callback(self): - callback_fn = self._build_callback() - if callback_fn is not None: + f, grad = self._build_callback() + if f is not None: eval_obj = self.obj_nl_expr is not None idx_cons = list(self.con_nl_expr_map.keys()) - self._execute(knitro.KN_add_eval_callback, eval_obj, idx_cons, callback_fn) + cb = self._execute(knitro.KN_add_eval_callback, eval_obj, idx_cons, f) + if grad is not None: + obj_var_idxs = ( + [self.var_map[id(v)] for v in self.obj_nl_expr.variables] + if self.obj_nl_expr is not None + else None + ) + jac_idx_cons, jac_idx_vars = [], [] + for i, con_nl_expr in self.con_nl_expr_map.items(): + idx_vars = [self.var_map[id(v)] for v in con_nl_expr.variables] + n_vars = len(idx_vars) + jac_idx_cons.extend([i] * n_vars) + jac_idx_vars.extend(idx_vars) + self._execute( + knitro.KN_set_cb_grad, + cb, + obj_var_idxs, + jac_idx_cons, + jac_idx_vars, + grad, + ) diff --git a/pyomo/contrib/solver/solvers/knitro/utils.py b/pyomo/contrib/solver/solvers/knitro/utils.py index 3d11062fd6b..d160a338dcd 100644 --- a/pyomo/contrib/solver/solvers/knitro/utils.py +++ b/pyomo/contrib/solver/solvers/knitro/utils.py @@ -19,6 +19,7 @@ from pyomo.core.base.constraint import Constraint, ConstraintData from pyomo.core.base.objective import Objective, ObjectiveData from pyomo.core.base.var import VarData +from pyomo.core.expr.calculus.diff_with_pyomo import reverse_sd def get_active_objectives(block: BlockData) -> List[ObjectiveData]: @@ -113,14 +114,26 @@ class NonlinearExpressionData: Attributes: body (Optional[Any]): The Pyomo expression representing the non-linear body. variables (List[VarData]): List of variables referenced in the expression. + grad (Optional[Mapping[VarData, Any]]): Gradient information for the non-linear expression. """ body: Optional[Any] variables: List[VarData] - - def __init__(self, expr: Optional[Any], variables: Iterable[VarData]): + grad: Optional[Mapping[VarData, Any]] + + def __init__( + self, + expr: Optional[Any], + variables: Iterable[VarData], + *, + compute_grad: bool = True, + ): self.body = expr self.variables = list(variables) + if compute_grad: + self.grad = reverse_sd(self.body) + else: + self.grad = None def create_evaluator(self, vmap: Mapping[int, int]): """ @@ -142,3 +155,31 @@ def _fn(x: List[float]) -> float: return value(self.body) return _fn + + def create_gradient_evaluator(self, vmap: Mapping[int, int]): + """ + Create a callable gradient evaluator for the non-linear expression. + + Args: + vmap (Mapping[int, int]): A mapping from variable id to index in the solver's variable vector. + + Returns: + Callable[[List[float]], List[float]]: A function that takes a list of variable values (x) + and returns the gradient of the expression with respect to its variables. + + Raises: + ValueError: If gradient information is not available for this expression. + """ + + if self.grad is None: + msg = "Gradient information is not available for this expression." + raise ValueError(msg) + + def _grad(x: List[float]) -> List[float]: + # Set the values of the Pyomo variables from the solver's vector `x` + for var in self.variables: + i = vmap[id(var)] + var.set_value(x[i]) + return [value(self.grad.get(var, 0.0)) for var in self.variables] + + return _grad From bea0cceec978637e1019a8938d65b308baf9b935 Mon Sep 17 00:00:00 2001 From: eminyouskn Date: Tue, 26 Aug 2025 16:13:05 -0400 Subject: [PATCH 14/15] Defer knitro import. --- pyomo/contrib/solver/solvers/knitro/api.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pyomo/contrib/solver/solvers/knitro/api.py b/pyomo/contrib/solver/solvers/knitro/api.py index e24e7abe966..a3f7d7eae9d 100644 --- a/pyomo/contrib/solver/solvers/knitro/api.py +++ b/pyomo/contrib/solver/solvers/knitro/api.py @@ -12,7 +12,7 @@ from pyomo.common.dependencies import attempt_import -import knitro +# import knitro -_, KNITRO_AVAILABLE = attempt_import("knitro") +knitro, KNITRO_AVAILABLE = attempt_import("knitro") KNITRO_VERSION = knitro.__version__ if KNITRO_AVAILABLE else "0.0.0" From 0d7a1624a08f6691d772012fbcb86cbcd6f96292 Mon Sep 17 00:00:00 2001 From: eminyouskn Date: Wed, 27 Aug 2025 10:14:04 -0400 Subject: [PATCH 15/15] Improve get_status. --- pyomo/contrib/solver/solvers/knitro/engine.py | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/pyomo/contrib/solver/solvers/knitro/engine.py b/pyomo/contrib/solver/solvers/knitro/engine.py index 5c54a74ce55..3014ffef108 100644 --- a/pyomo/contrib/solver/solvers/knitro/engine.py +++ b/pyomo/contrib/solver/solvers/knitro/engine.py @@ -38,12 +38,15 @@ class Engine: obj_nl_expr: Optional[NonlinearExpressionData] con_nl_expr_map: MutableMapping[int, NonlinearExpressionData] + _status: Optional[int] + def __init__(self): - self._kc = None self.var_map = {} self.con_map = {} self.obj_nl_expr = None self.con_nl_expr_map = {} + self._kc = None + self._status = None def __del__(self): self.close() @@ -53,7 +56,7 @@ def renew(self): self._kc = Package.create_context() def close(self): - if hasattr(self, "_kc") and self._kc is not None: + if self._kc is not None: self._execute(knitro.KN_free) self._kc = None @@ -151,11 +154,14 @@ def set_obj(self, obj: ObjectiveData): def solve(self) -> int: self._register_callback() - return self._execute(knitro.KN_solve) + self._status = self._execute(knitro.KN_solve) + return self._status def get_status(self) -> int: - status, _, _, _ = self._execute(knitro.KN_get_solution) - return status + if self._status is None: + msg = "Solver has not been run yet. Since the solver has not been executed, no status is available." + raise RuntimeError(msg) + return self._status def get_num_iters(self) -> int: return self._execute(knitro.KN_get_number_iters)