From 49d3bcb1fb43b13d413cf898169ecd34def8f3ed Mon Sep 17 00:00:00 2001 From: eminyouskn Date: Thu, 21 Aug 2025 19:44:03 -0400 Subject: [PATCH 01/64] 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 c5fe4ea8faa4fb264b12a4c03a1a0023097c005f Mon Sep 17 00:00:00 2001 From: eminyouskn Date: Thu, 21 Aug 2025 20:00:07 -0400 Subject: [PATCH 02/64] 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 e462a368db3b7f30efaa20c56ac3f1e03eb2064d Mon Sep 17 00:00:00 2001 From: eminyouskn Date: Thu, 21 Aug 2025 20:28:42 -0400 Subject: [PATCH 03/64] 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 7a6f16b4e1e539a2498676485449956e1b9672d3 Mon Sep 17 00:00:00 2001 From: eminyouskn Date: Fri, 22 Aug 2025 10:13:37 -0400 Subject: [PATCH 04/64] 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 fa8e00aeb4b675269f19fa2a99cb32a187c30d9d Mon Sep 17 00:00:00 2001 From: eminyouskn Date: Fri, 22 Aug 2025 10:59:29 -0400 Subject: [PATCH 05/64] 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 d43859b79a5aba7dfbb5bd241793e10484202738 Mon Sep 17 00:00:00 2001 From: eminyouskn Date: Fri, 22 Aug 2025 13:10:37 -0400 Subject: [PATCH 06/64] 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 97d6f7bbdb305076870eabf39a39c81c4ae0f737 Mon Sep 17 00:00:00 2001 From: eminyouskn Date: Fri, 22 Aug 2025 13:14:53 -0400 Subject: [PATCH 07/64] 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 aa3cc1965d5a133fe94f7cf9cb9bd37e7e63c789 Mon Sep 17 00:00:00 2001 From: eminyouskn Date: Mon, 25 Aug 2025 13:44:16 -0400 Subject: [PATCH 08/64] 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 a5408c80a56969df6f729c6c64220b793b9e69eb Mon Sep 17 00:00:00 2001 From: eminyouskn Date: Mon, 25 Aug 2025 14:01:02 -0400 Subject: [PATCH 09/64] 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 b337deef7e1f7afdf7b398077dd234967c84cad5 Mon Sep 17 00:00:00 2001 From: eminyouskn Date: Mon, 25 Aug 2025 15:34:00 -0400 Subject: [PATCH 10/64] 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 0da585744d554380240f68d2c488df3c281c3875 Mon Sep 17 00:00:00 2001 From: eminyouskn Date: Mon, 25 Aug 2025 16:24:06 -0400 Subject: [PATCH 11/64] 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 2bfe480fab4e62fb4a1f8f80c43be98dbabc1ca5 Mon Sep 17 00:00:00 2001 From: eminyouskn Date: Mon, 25 Aug 2025 16:54:32 -0400 Subject: [PATCH 12/64] 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 90305dfa5e68ba693c0fbee5e9870d8878ab167e Mon Sep 17 00:00:00 2001 From: eminyouskn Date: Tue, 26 Aug 2025 16:07:20 -0400 Subject: [PATCH 13/64] 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 6c2ee44ca847342ab8328f889d75f3e0a393ec2c Mon Sep 17 00:00:00 2001 From: eminyouskn Date: Tue, 26 Aug 2025 16:13:05 -0400 Subject: [PATCH 14/64] 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 8dc0b0db263b38f575ee35d0130504ff8fd2ff87 Mon Sep 17 00:00:00 2001 From: eminyouskn Date: Wed, 27 Aug 2025 10:14:04 -0400 Subject: [PATCH 15/64] 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) From c6e2c14fd30d2895ce954aa4c543b8c5dd1dcd54 Mon Sep 17 00:00:00 2001 From: eminyouskn Date: Tue, 16 Sep 2025 13:56:12 -0400 Subject: [PATCH 16/64] Refactor imports --- pyomo/contrib/solver/solvers/knitro/base.py | 2 +- pyomo/contrib/solver/solvers/knitro/direct.py | 3 --- 2 files changed, 1 insertion(+), 4 deletions(-) diff --git a/pyomo/contrib/solver/solvers/knitro/base.py b/pyomo/contrib/solver/solvers/knitro/base.py index ed408c30c04..2ff0f9f1274 100644 --- a/pyomo/contrib/solver/solvers/knitro/base.py +++ b/pyomo/contrib/solver/solvers/knitro/base.py @@ -11,7 +11,7 @@ from abc import abstractmethod -from collections.abc import Iterable, Mapping, Sequence +from collections.abc import Mapping, Sequence from datetime import datetime, timezone from io import StringIO from typing import Optional diff --git a/pyomo/contrib/solver/solvers/knitro/direct.py b/pyomo/contrib/solver/solvers/knitro/direct.py index 53391e8349a..b24ccae389d 100644 --- a/pyomo/contrib/solver/solvers/knitro/direct.py +++ b/pyomo/contrib/solver/solvers/knitro/direct.py @@ -11,13 +11,10 @@ from pyomo.common.timing import HierarchicalTimer -from pyomo.contrib.solver.common.results import TerminationCondition -from pyomo.contrib.solver.common.util import NoOptimalSolutionError from pyomo.core.base.block import BlockData from .base import SolverBase from .config import Config -from .solution import SolutionLoader class Solver(SolverBase): From 044831827d2eeb4accd883102b2a5e2cf07e0a29 Mon Sep 17 00:00:00 2001 From: eminyouskn Date: Tue, 16 Sep 2025 13:56:35 -0400 Subject: [PATCH 17/64] Use component_set instead of dict. --- pyomo/contrib/solver/solvers/knitro/utils.py | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/pyomo/contrib/solver/solvers/knitro/utils.py b/pyomo/contrib/solver/solvers/knitro/utils.py index d160a338dcd..cae138aa5f2 100644 --- a/pyomo/contrib/solver/solvers/knitro/utils.py +++ b/pyomo/contrib/solver/solvers/knitro/utils.py @@ -10,9 +10,10 @@ # ___________________________________________________________________________ -from collections.abc import Iterable, Mapping, MutableMapping +from collections.abc import Iterable, Mapping, MutableSet from typing import Any, List, Optional +from pyomo.common.collections.component_set import ComponentSet 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 @@ -70,10 +71,10 @@ class Problem: objs: List[ObjectiveData] cons: List[ConstraintData] variables: List[VarData] - _var_map: MutableMapping[int, VarData] + _vars: MutableSet[VarData] def __init__(self, block: Optional[BlockData] = None): - self._var_map = {} + self._vars = ComponentSet() self.objs = [] self.cons = [] self.variables = [] @@ -84,7 +85,7 @@ def clear(self): self.objs.clear() self.cons.clear() self.variables.clear() - self._var_map.clear() + self._vars.clear() def set_block(self, block: BlockData): self.clear() @@ -99,12 +100,12 @@ def add_block(self, block: BlockData): for obj in new_objs: _, variables, _, _ = collect_vars_and_named_exprs(obj.expr) for v in variables: - self._var_map[id(v)] = v + self._vars.add(v) for con in new_cons: _, variables, _, _ = collect_vars_and_named_exprs(con.body) for v in variables: - self._var_map[id(v)] = v - self.variables.extend(self._var_map.values()) + self._vars.add(v) + self.variables.extend(self._vars) class NonlinearExpressionData: From 8d79af1c89f7019c8ae05539e4150558456f62cf Mon Sep 17 00:00:00 2001 From: eminyouskn Date: Tue, 16 Sep 2025 15:20:44 -0400 Subject: [PATCH 18/64] Fix level default arg. --- pyomo/contrib/solver/solvers/knitro/engine.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/pyomo/contrib/solver/solvers/knitro/engine.py b/pyomo/contrib/solver/solvers/knitro/engine.py index 3014ffef108..e5608d1825d 100644 --- a/pyomo/contrib/solver/solvers/knitro/engine.py +++ b/pyomo/contrib/solver/solvers/knitro/engine.py @@ -196,7 +196,9 @@ def set_options(self, **options): setter_fn = knitro.KN_set_char_param self._execute(setter_fn, param_id, val) - def set_outlev(self, level: int = knitro.KN_OUTLEV_ALL): + def set_outlev(self, level: Optional[int] = None): + if level is None: + level = knitro.KN_OUTLEV_ALL self.set_options(outlev=level) def set_time_limit(self, time_limit: float): From 3de94599ce21640c23262c77b3277aeaa724cc7c Mon Sep 17 00:00:00 2001 From: eminyouskn Date: Tue, 16 Sep 2025 15:23:50 -0400 Subject: [PATCH 19/64] Skip test if knitro is not available. --- pyomo/contrib/solver/tests/solvers/test_knitro_direct.py | 1 + 1 file changed, 1 insertion(+) diff --git a/pyomo/contrib/solver/tests/solvers/test_knitro_direct.py b/pyomo/contrib/solver/tests/solvers/test_knitro_direct.py index 6eac257e3ae..62d1ba0db22 100644 --- a/pyomo/contrib/solver/tests/solvers/test_knitro_direct.py +++ b/pyomo/contrib/solver/tests/solvers/test_knitro_direct.py @@ -81,6 +81,7 @@ def test_available_cache(self): self.assertIsNotNone(opt._available_cache) +@unittest.skipIf(not avail, "KNITRO solver is not available") class TestKnitroDirectSolver(unittest.TestCase): def setUp(self): self.opt = knitro.KnitroDirectSolver() From 9d0aa7b9030eb0b48990bee541c0e0cd8ec33bd9 Mon Sep 17 00:00:00 2001 From: eminyouskn Date: Tue, 16 Sep 2025 16:13:21 -0400 Subject: [PATCH 20/64] Add KnitroDirectSolver to solver lists --- .../solver/tests/solvers/test_solvers.py | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/pyomo/contrib/solver/tests/solvers/test_solvers.py b/pyomo/contrib/solver/tests/solvers/test_solvers.py index 5ab36554061..65bea452632 100644 --- a/pyomo/contrib/solver/tests/solvers/test_solvers.py +++ b/pyomo/contrib/solver/tests/solvers/test_solvers.py @@ -29,6 +29,7 @@ ) from pyomo.contrib.solver.common.base import SolverBase from pyomo.contrib.solver.common.factory import SolverFactory +from pyomo.contrib.solver.solvers.knitro import KnitroDirectSolver from pyomo.contrib.solver.solvers.ipopt import Ipopt from pyomo.contrib.solver.solvers.gurobi_persistent import GurobiPersistent from pyomo.contrib.solver.solvers.gurobi_direct import GurobiDirect @@ -51,16 +52,18 @@ ('gurobi_direct', GurobiDirect), ('ipopt', Ipopt), ('highs', Highs), + ('knitro_direct', KnitroDirectSolver), ] mip_solvers = [ ('gurobi_persistent', GurobiPersistent), ('gurobi_direct', GurobiDirect), ('highs', Highs), + ('knitro_direct', KnitroDirectSolver), ] -nlp_solvers = [('ipopt', Ipopt)] -qcp_solvers = [('gurobi_persistent', GurobiPersistent), ('ipopt', Ipopt)] +nlp_solvers = [('ipopt', Ipopt), ('knitro_direct', KnitroDirectSolver)] +qcp_solvers = [('gurobi_persistent', GurobiPersistent), ('ipopt', Ipopt), ('knitro_direct', KnitroDirectSolver)] qp_solvers = qcp_solvers + [("highs", Highs)] -miqcqp_solvers = [('gurobi_persistent', GurobiPersistent)] +miqcqp_solvers = [('gurobi_persistent', GurobiPersistent), ('knitro_direct', KnitroDirectSolver)] nl_solvers = [('ipopt', Ipopt)] nl_solvers_set = {i[0] for i in nl_solvers} @@ -167,7 +170,7 @@ def test_inequality( res = opt.solve(m) self.assertEqual(res.solution_status, SolutionStatus.optimal) self.assertAlmostEqual(m.x.value, 0) - self.assertAlmostEqual(m.y.value, 1) + self.assertAlmostEqual(m.y.value, 1, places=5) duals = res.solution_loader.get_duals() # the sign convention is based on the (lower, body, upper) representation of the constraint, # so we need to make sure the constraint body is what we expect @@ -190,7 +193,7 @@ def test_inequality( res = opt.solve(m) self.assertEqual(res.solution_status, SolutionStatus.optimal) self.assertAlmostEqual(m.x.value, 0) - self.assertAlmostEqual(m.y.value, 1) + self.assertAlmostEqual(m.y.value, 1, places=5) duals = res.solution_loader.get_duals() # the sign convention is based on the (lower, body, upper) representation of the constraint, # so we need to make sure the constraint body is what we expect @@ -390,7 +393,7 @@ def test_inequality_max( res = opt.solve(m) self.assertEqual(res.solution_status, SolutionStatus.optimal) self.assertAlmostEqual(m.x.value, 0) - self.assertAlmostEqual(m.y.value, 1) + self.assertAlmostEqual(m.y.value, 1, places=5) duals = res.solution_loader.get_duals() # the sign convention is based on the (lower, body, upper) representation of the constraint, # so we need to make sure the constraint body is what we expect @@ -413,7 +416,7 @@ def test_inequality_max( res = opt.solve(m) self.assertEqual(res.solution_status, SolutionStatus.optimal) self.assertAlmostEqual(m.x.value, 0) - self.assertAlmostEqual(m.y.value, 1) + self.assertAlmostEqual(m.y.value, 1, places=5) duals = res.solution_loader.get_duals() # the sign convention is based on the (lower, body, upper) representation of the constraint, # so we need to make sure the constraint body is what we expect From 0573bbdd7958c910b7306cf3a92e908b871d0d2e Mon Sep 17 00:00:00 2001 From: eminyouskn Date: Tue, 16 Sep 2025 16:13:32 -0400 Subject: [PATCH 21/64] Add methods for retrieving reduced costs and update duals retrieval in Knitro solver --- pyomo/contrib/solver/solvers/knitro/api.py | 4 ++-- pyomo/contrib/solver/solvers/knitro/base.py | 12 ++++++++++-- pyomo/contrib/solver/solvers/knitro/engine.py | 6 +++++- pyomo/contrib/solver/solvers/knitro/solution.py | 8 +++++++- 4 files changed, 24 insertions(+), 6 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 2ff0f9f1274..aa6b9c2400c 100644 --- a/pyomo/contrib/solver/solvers/knitro/base.py +++ b/pyomo/contrib/solver/solvers/knitro/base.py @@ -171,13 +171,21 @@ def get_primals(self, vars_to_load: Optional[Sequence[VarData]] = None): return NoSolutionError() return ComponentMap([(var, x[i]) for i, var in enumerate(vars_to_load)]) + def get_reduced_costs(self, vars_to_load: Optional[Sequence[VarData]] = None): + if vars_to_load is None: + vars_to_load = self.get_vars() + rc = self._engine.get_var_duals(vars_to_load) + if rc is None: + return NoDualsError() + return ComponentMap([(var, -rc[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) + y = self._engine.get_con_duals(cons_to_load) if y is None: return NoDualsError() - return ComponentMap([(con, y[i]) for i, con in enumerate(cons_to_load)]) + 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/engine.py b/pyomo/contrib/solver/solvers/knitro/engine.py index e5608d1825d..8167fcbe704 100644 --- a/pyomo/contrib/solver/solvers/knitro/engine.py +++ b/pyomo/contrib/solver/solvers/knitro/engine.py @@ -177,7 +177,11 @@ 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]]: + def get_var_duals(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_dual_values, idx_vars) + + def get_con_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) diff --git a/pyomo/contrib/solver/solvers/knitro/solution.py b/pyomo/contrib/solver/solvers/knitro/solution.py index 245aa6f39e0..ed5e4d559ee 100644 --- a/pyomo/contrib/solver/solvers/knitro/solution.py +++ b/pyomo/contrib/solver/solvers/knitro/solution.py @@ -22,6 +22,9 @@ def get_num_solutions(self) -> int: ... def get_primals( self, vars_to_load: Optional[Sequence[VarData]] = None ) -> Mapping[VarData, float]: ... + def get_reduced_costs( + 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]: ... @@ -44,7 +47,10 @@ def get_vars( def get_primals(self, vars_to_load=None): return self.get_vars(vars_to_load) + def get_reduced_costs(self, vars_to_load = None): + return self._provider.get_reduced_costs(vars_to_load) + def get_duals( self, cons_to_load: Optional[Sequence[ConstraintData]] = None ) -> Mapping[ConstraintData, float]: - return self._provider.get_duals(cons_to_load) + return self._provider.get_duals(cons_to_load) \ No newline at end of file From bc295f12826bc8def48c2e55a6544a0b303e2fa1 Mon Sep 17 00:00:00 2001 From: eminyouskn Date: Tue, 16 Sep 2025 17:16:10 -0400 Subject: [PATCH 22/64] black format --- pyomo/contrib/solver/solvers/knitro/solution.py | 4 ++-- pyomo/contrib/solver/tests/solvers/test_solvers.py | 11 +++++++++-- 2 files changed, 11 insertions(+), 4 deletions(-) diff --git a/pyomo/contrib/solver/solvers/knitro/solution.py b/pyomo/contrib/solver/solvers/knitro/solution.py index ed5e4d559ee..5f524d8015e 100644 --- a/pyomo/contrib/solver/solvers/knitro/solution.py +++ b/pyomo/contrib/solver/solvers/knitro/solution.py @@ -47,10 +47,10 @@ def get_vars( def get_primals(self, vars_to_load=None): return self.get_vars(vars_to_load) - def get_reduced_costs(self, vars_to_load = None): + def get_reduced_costs(self, vars_to_load=None): return self._provider.get_reduced_costs(vars_to_load) def get_duals( self, cons_to_load: Optional[Sequence[ConstraintData]] = None ) -> Mapping[ConstraintData, float]: - return self._provider.get_duals(cons_to_load) \ No newline at end of file + return self._provider.get_duals(cons_to_load) diff --git a/pyomo/contrib/solver/tests/solvers/test_solvers.py b/pyomo/contrib/solver/tests/solvers/test_solvers.py index 65bea452632..3a184abb0b6 100644 --- a/pyomo/contrib/solver/tests/solvers/test_solvers.py +++ b/pyomo/contrib/solver/tests/solvers/test_solvers.py @@ -61,9 +61,16 @@ ('knitro_direct', KnitroDirectSolver), ] nlp_solvers = [('ipopt', Ipopt), ('knitro_direct', KnitroDirectSolver)] -qcp_solvers = [('gurobi_persistent', GurobiPersistent), ('ipopt', Ipopt), ('knitro_direct', KnitroDirectSolver)] +qcp_solvers = [ + ('gurobi_persistent', GurobiPersistent), + ('ipopt', Ipopt), + ('knitro_direct', KnitroDirectSolver), +] qp_solvers = qcp_solvers + [("highs", Highs)] -miqcqp_solvers = [('gurobi_persistent', GurobiPersistent), ('knitro_direct', KnitroDirectSolver)] +miqcqp_solvers = [ + ('gurobi_persistent', GurobiPersistent), + ('knitro_direct', KnitroDirectSolver), +] nl_solvers = [('ipopt', Ipopt)] nl_solvers_set = {i[0] for i in nl_solvers} From f1c8f08c5ebb747c0bf271c1c9d01803c89398d2 Mon Sep 17 00:00:00 2001 From: eminyouskn Date: Tue, 16 Sep 2025 18:02:55 -0400 Subject: [PATCH 23/64] Defer import knitro --- 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 f976908512bc68b01c83f3e72426fc666ebdb2f0 Mon Sep 17 00:00:00 2001 From: eminyouskn Date: Fri, 19 Sep 2025 12:13:21 -0400 Subject: [PATCH 24/64] Handle no objective case. --- pyomo/contrib/solver/solvers/knitro/base.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/pyomo/contrib/solver/solvers/knitro/base.py b/pyomo/contrib/solver/solvers/knitro/base.py index aa6b9c2400c..cbd4cbf9948 100644 --- a/pyomo/contrib/solver/solvers/knitro/base.py +++ b/pyomo/contrib/solver/solvers/knitro/base.py @@ -133,7 +133,10 @@ def _postsolve(self, config: Config, timer: HierarchicalTimer) -> Results: results.solver_config = config results.solution_status = self.get_solution_status(status) results.termination_condition = self.get_termination_condition(status) - results.incumbent_objective = self._engine.get_obj_value() + if self._problem.objs: + results.incumbent_objective = self._engine.get_obj_value() + else: + results.incumbent_objective = None results.iteration_count = self._engine.get_num_iters() results.timing_info.solve_time = self._engine.get_solve_time() results.timing_info.timer = timer From 196b21f3eacaa261234e3964b0980878c18b1b62 Mon Sep 17 00:00:00 2001 From: eminyouskn Date: Fri, 19 Sep 2025 12:16:33 -0400 Subject: [PATCH 25/64] Fix status mapping. --- pyomo/contrib/solver/solvers/knitro/base.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/pyomo/contrib/solver/solvers/knitro/base.py b/pyomo/contrib/solver/solvers/knitro/base.py index cbd4cbf9948..84e9029b302 100644 --- a/pyomo/contrib/solver/solvers/knitro/base.py +++ b/pyomo/contrib/solver/solvers/knitro/base.py @@ -223,7 +223,11 @@ def get_termination_condition(status: int) -> TerminationCondition: return TerminationCondition.convergenceCriteriaSatisfied elif status == knitro.KN_RC_INFEAS_NO_IMPROVE: return TerminationCondition.locallyInfeasible - elif status == knitro.KN_RC_INFEASIBLE: + elif ( + status == knitro.KN_RC_INFEASIBLE + or status == knitro.KN_RC_INFEAS_CON_BOUNDS + or status == knitro.KN_RC_INFEAS_VAR_BOUNDS + ): return TerminationCondition.provenInfeasible elif ( status == knitro.KN_RC_UNBOUNDED_OR_INFEAS From a1e6eb621b3e43e393edd557a59071bbe2841368 Mon Sep 17 00:00:00 2001 From: eminyouskn Date: Fri, 19 Sep 2025 12:38:12 -0400 Subject: [PATCH 26/64] black format --- pyomo/contrib/solver/solvers/knitro/base.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyomo/contrib/solver/solvers/knitro/base.py b/pyomo/contrib/solver/solvers/knitro/base.py index 84e9029b302..d53453196f4 100644 --- a/pyomo/contrib/solver/solvers/knitro/base.py +++ b/pyomo/contrib/solver/solvers/knitro/base.py @@ -226,7 +226,7 @@ def get_termination_condition(status: int) -> TerminationCondition: 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_VAR_BOUNDS ): return TerminationCondition.provenInfeasible elif ( From b68b2a8cf0dec60025ef70def44b6bf0bf9e2073 Mon Sep 17 00:00:00 2001 From: eminyouskn Date: Fri, 19 Sep 2025 16:32:00 -0400 Subject: [PATCH 27/64] Refactor and fix stale managment flag. --- pyomo/contrib/solver/solvers/knitro/base.py | 82 ++++++++++---- pyomo/contrib/solver/solvers/knitro/engine.py | 107 +++++++++++------- .../contrib/solver/solvers/knitro/solution.py | 60 ++++++++-- 3 files changed, 176 insertions(+), 73 deletions(-) diff --git a/pyomo/contrib/solver/solvers/knitro/base.py b/pyomo/contrib/solver/solvers/knitro/base.py index d53453196f4..d96ff841b9e 100644 --- a/pyomo/contrib/solver/solvers/knitro/base.py +++ b/pyomo/contrib/solver/solvers/knitro/base.py @@ -11,13 +11,13 @@ from abc import abstractmethod -from collections.abc import Mapping, Sequence +from collections.abc import Callable, Iterable, Mapping, Sequence from datetime import datetime, timezone from io import StringIO -from typing import Optional +from typing import List, Optional, Type, TypeVar, Union from pyomo.common.collections import ComponentMap -from pyomo.common.errors import ApplicationError +from pyomo.common.errors import ApplicationError, PyomoException from pyomo.common.numeric_types import value from pyomo.common.tee import TeeStream, capture_output from pyomo.common.timing import HierarchicalTimer @@ -31,6 +31,7 @@ IncompatibleModelError, NoDualsError, NoOptimalSolutionError, + NoReducedCostsError, NoSolutionError, ) from pyomo.core.base.block import BlockData @@ -45,6 +46,24 @@ from .utils import Problem from .solution import SolutionLoader, SolutionProvider +T = TypeVar("T", bound=Union[VarData, ConstraintData]) + + +def helper( + to_load: Optional[Sequence[T]], + fetch_all: Callable[[], Sequence[T]], + loader: Callable[[Iterable[T]], Optional[List[float]]], + error_type: Type[PyomoException], + flip_sign: bool = False, +) -> Mapping[T, float]: + if to_load is None: + to_load = fetch_all() + x = loader(to_load) + if x is None: + raise error_type() + sign = -1.0 if flip_sign else 1.0 + return ComponentMap([(k, sign * x[i]) for i, k in enumerate(to_load)]) + class SolverBase(SolutionProvider, PackageChecker, base.SolverBase): CONFIG = Config() @@ -110,9 +129,11 @@ 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: + StaleFlagManager.mark_all_as_stale(delayed=True) for var in self.get_vars(): if id(var) in self._saved_var_values: var.set_value(self._saved_var_values[id(var)]) + StaleFlagManager.mark_all_as_stale() @abstractmethod def _presolve( @@ -133,7 +154,11 @@ def _postsolve(self, config: Config, timer: HierarchicalTimer) -> Results: results.solver_config = config results.solution_status = self.get_solution_status(status) results.termination_condition = self.get_termination_condition(status) - if self._problem.objs: + if self._problem.objs and results.termination_condition in { + TerminationCondition.convergenceCriteriaSatisfied, + TerminationCondition.iterationLimit, + TerminationCondition.maxTimeLimit, + }: results.incumbent_objective = self._engine.get_obj_value() else: results.incumbent_objective = None @@ -148,7 +173,14 @@ def _postsolve(self, config: Config, timer: HierarchicalTimer) -> Results: ): raise NoOptimalSolutionError() - results.solution_loader = SolutionLoader(self) + results.solution_loader = SolutionLoader( + self, + has_primals=results.solution_status + not in {SolutionStatus.infeasible, SolutionStatus.noSolution}, + has_reduced_costs=results.solution_status == SolutionStatus.optimal, + has_duals=results.solution_status + not in {SolutionStatus.infeasible, SolutionStatus.noSolution}, + ) if config.load_solutions: timer.start("load_solutions") results.solution_loader.load_vars() @@ -166,29 +198,31 @@ def get_cons(self): return self._problem.cons 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)]) + return helper( + vars_to_load, + self.get_vars, + self._engine.get_var_primals, + NoSolutionError, + flip_sign=False, + ) def get_reduced_costs(self, vars_to_load: Optional[Sequence[VarData]] = None): - if vars_to_load is None: - vars_to_load = self.get_vars() - rc = self._engine.get_var_duals(vars_to_load) - if rc is None: - return NoDualsError() - return ComponentMap([(var, -rc[i]) for i, var in enumerate(vars_to_load)]) + return helper( + vars_to_load, + self.get_vars, + self._engine.get_var_duals, + NoReducedCostsError, + flip_sign=True, + ) 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_con_duals(cons_to_load) - if y is None: - return NoDualsError() - return ComponentMap([(con, -y[i]) for i, con in enumerate(cons_to_load)]) + return helper( + cons_to_load, + self.get_cons, + self._engine.get_con_duals, + NoDualsError, + flip_sign=True, + ) def get_num_solutions(self): return self._engine.get_num_solutions() diff --git a/pyomo/contrib/solver/solvers/knitro/engine.py b/pyomo/contrib/solver/solvers/knitro/engine.py index 8167fcbe704..d1a237ecd9d 100644 --- a/pyomo/contrib/solver/solvers/knitro/engine.py +++ b/pyomo/contrib/solver/solvers/knitro/engine.py @@ -37,14 +37,16 @@ class Engine: con_map: MutableMapping[int, int] obj_nl_expr: Optional[NonlinearExpressionData] con_nl_expr_map: MutableMapping[int, NonlinearExpressionData] + compute_nl_grad: bool _status: Optional[int] - def __init__(self): + def __init__(self, *, compute_nl_grad: bool = True): self.var_map = {} self.con_map = {} self.obj_nl_expr = None self.con_nl_expr_map = {} + self.compute_nl_grad = compute_nl_grad self._kc = None self._status = None @@ -66,27 +68,27 @@ def add_vars(self, variables: Iterable[VarData]): if idx_vars is None: return - for i, var in zip(idx_vars, variables): - self.var_map[id(var)] = i + for var, idx in zip(variables, idx_vars, strict=True): + self.var_map[id(var)] = idx var_types, fxbnds, lobnds, upbnds = {}, {}, {}, {} for var in variables: - i = self.var_map[id(var)] + idx = self.var_map[id(var)] if var.is_binary(): - var_types[i] = knitro.KN_VARTYPE_BINARY + var_types[idx] = knitro.KN_VARTYPE_BINARY elif var.is_integer(): - var_types[i] = knitro.KN_VARTYPE_INTEGER + var_types[idx] = 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) + fxbnds[idx] = value(var.value) else: if var.has_lb(): - lobnds[i] = value(var.lb) + lobnds[idx] = value(var.lb) if var.has_ub(): - upbnds[i] = value(var.ub) + upbnds[idx] = 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()) @@ -99,38 +101,40 @@ def add_cons(self, cons: Iterable[ConstraintData]): if idx_cons is None: return - for i, con in zip(idx_cons, cons): - self.con_map[id(con)] = i + for con, idx in zip(cons, idx_cons, strict=True): + self.con_map[id(con)] = idx eqbnds, lobnds, upbnds = {}, {}, {} for con in cons: - i = self.con_map[id(con)] + idx = self.con_map[id(con)] if con.equality: - eqbnds[i] = value(con.lower) + eqbnds[idx] = value(con.lower) else: if con.has_lb(): - lobnds[i] = value(con.lower) + lobnds[idx] = value(con.lower) if con.has_ub(): - upbnds[i] = value(con.upper) + upbnds[idx] = 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)] + idx = self.con_map[id(con)] repn = generate_standard_repn(con.body) - self._add_expr_structs_from_repn( + self._add_expr_structs_using_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_const_fn=partial(self._execute, knitro.KN_add_con_constants, idx), + add_lin_fn=partial(self._execute, knitro.KN_add_con_linear_struct, idx), add_quad_fn=partial( - self._execute, knitro.KN_add_con_quadratic_struct, i + self._execute, knitro.KN_add_con_quadratic_struct, idx ), ) if repn.nonlinear_expr is not None: - self.con_nl_expr_map[i] = NonlinearExpressionData( - repn.nonlinear_expr, repn.nonlinear_vars + self.con_nl_expr_map[idx] = NonlinearExpressionData( + repn.nonlinear_expr, + repn.nonlinear_vars, + compute_grad=self.compute_nl_grad, ) def set_obj(self, obj: ObjectiveData): @@ -141,7 +145,7 @@ def set_obj(self, obj: ObjectiveData): ) self._execute(knitro.KN_set_obj_goal, obj_goal) repn = generate_standard_repn(obj.expr) - self._add_expr_structs_from_repn( + self._add_expr_structs_using_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), @@ -149,7 +153,9 @@ def set_obj(self, obj: ObjectiveData): ) if repn.nonlinear_expr is not None: self.obj_nl_expr = NonlinearExpressionData( - repn.nonlinear_expr, repn.nonlinear_vars + repn.nonlinear_expr, + repn.nonlinear_vars, + compute_grad=self.compute_nl_grad, ) def solve(self) -> int: @@ -173,16 +179,22 @@ def get_num_solutions(self) -> int: 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] + def get_var_idxs(self, variables: Iterable[VarData]) -> List[int]: + return [self.var_map[id(var)] for var in variables] + + def get_con_idxs(self, constraints: Iterable[ConstraintData]) -> List[int]: + return [self.con_map[id(con)] for con in constraints] + + def get_var_primals(self, variables: Iterable[VarData]) -> Optional[List[float]]: + idx_vars = self.get_var_idxs(variables) return self._execute(knitro.KN_get_var_primal_values, idx_vars) def get_var_duals(self, variables: Iterable[VarData]) -> Optional[List[float]]: - idx_vars = [self.var_map[id(var)] for var in variables] + idx_vars = self.get_var_idxs(variables) return self._execute(knitro.KN_get_var_dual_values, idx_vars) def get_con_duals(self, cons: Iterable[ConstraintData]) -> Optional[List[float]]: - idx_cons = [self.con_map[id(con)] for con in cons] + idx_cons = self.get_con_idxs(cons) return self._execute(knitro.KN_get_con_dual_values, idx_cons) def get_obj_value(self) -> Optional[float]: @@ -219,7 +231,7 @@ def _execute(self, api_fn, *args, **kwargs): raise RuntimeError(msg) return api_fn(self._kc, *args, **kwargs) - def _add_expr_structs_from_repn( + def _add_expr_structs_using_repn( self, repn: StandardRepn, add_const_fn: Callable[[float], None], @@ -229,15 +241,15 @@ def _add_expr_structs_from_repn( 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] + idx_lin_vars = self.get_var_idxs(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] + idx_quad_vars1 = self.get_var_idxs(quad_vars1) + idx_quad_vars2 = self.get_var_idxs(quad_vars2) add_quad_fn(idx_quad_vars1, idx_quad_vars2, list(repn.quadratic_coefs)) - def _build_callback(self): + def _build_callback_eval(self): obj_eval = ( self.obj_nl_expr.create_evaluator(self.var_map) if self.obj_nl_expr is not None @@ -249,8 +261,8 @@ def _build_callback(self): for i, nl_expr in self.con_nl_expr_map.items() } - if obj_eval is not None and not con_eval_map: - return None, None + if obj_eval is None and not con_eval_map: + return None def _callback_eval(_, cb, req, res, data=None): if req.type != knitro.KN_RC_EVALFC: @@ -262,19 +274,25 @@ def _callback_eval(_, cb, req, res, data=None): res.c[i] = con_eval(x) return 0 + return _callback_eval + + def _build_callback_grad(self): + if not self.compute_nl_grad: + return None + 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 + i: expr.create_gradient_evaluator(self.var_map) + for i, expr in self.con_nl_expr_map.items() + if expr.grad is not None } if obj_grad is None and not con_grad_map: - return _callback_eval, None + return None def _callback_grad(_, cb, req, res, data=None): if req.type != knitro.KN_RC_EVALGA: @@ -292,7 +310,12 @@ def _callback_grad(_, cb, req, res, data=None): k += 1 return 0 - return _callback_eval, _callback_grad + return _callback_grad + + def _build_callback(self): + callback_eval = self._build_callback_eval() + callback_grad = self._build_callback_grad() + return callback_eval, callback_grad def _register_callback(self): f, grad = self._build_callback() @@ -302,13 +325,13 @@ def _register_callback(self): 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] + self.get_var_idxs(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] + idx_vars = self.get_var_idxs(con_nl_expr.variables) n_vars = len(idx_vars) jac_idx_cons.extend([i] * n_vars) jac_idx_vars.extend(idx_vars) diff --git a/pyomo/contrib/solver/solvers/knitro/solution.py b/pyomo/contrib/solver/solvers/knitro/solution.py index 5f524d8015e..c284990902e 100644 --- a/pyomo/contrib/solver/solvers/knitro/solution.py +++ b/pyomo/contrib/solver/solvers/knitro/solution.py @@ -9,10 +9,16 @@ # This software is distributed under the 3-clause BSD License. # ___________________________________________________________________________ -from collections.abc import Mapping, Sequence -from typing import Optional, Protocol +from collections.abc import Callable, Mapping, Sequence +from typing import Optional, Protocol, Type, TypeVar, Union +from pyomo.common.errors import PyomoException from pyomo.contrib.solver.common.solution_loader import SolutionLoaderBase +from pyomo.contrib.solver.common.util import ( + NoDualsError, + NoReducedCostsError, + NoSolutionError, +) from pyomo.core.base.constraint import ConstraintData from pyomo.core.base.var import VarData @@ -30,10 +36,39 @@ def get_duals( ) -> Mapping[ConstraintData, float]: ... +T = TypeVar("T", bound=Union[VarData, ConstraintData]) + + +def helper( + to_load: Optional[Sequence[T]], + loader: Callable[[Optional[Sequence[T]]], Mapping[T, float]], + check: bool, + error_type: Type[PyomoException], +) -> Mapping[T, float]: + if not check: + raise error_type() + return loader(to_load) + + class SolutionLoader(SolutionLoaderBase): - def __init__(self, provider: SolutionProvider): + _provider: SolutionProvider + has_primals: bool + has_reduced_costs: bool + has_duals: bool + + def __init__( + self, + provider: SolutionProvider, + *, + has_primals: bool = True, + has_reduced_costs: bool = True, + has_duals: bool = True, + ): super().__init__() self._provider = provider + self.has_primals = has_primals + self.has_reduced_costs = has_reduced_costs + self.has_duals = has_duals def get_number_of_solutions(self) -> int: return self._provider.get_num_solutions() @@ -41,16 +76,27 @@ def get_number_of_solutions(self) -> int: def get_vars( self, vars_to_load: Optional[Sequence[VarData]] = None ) -> Mapping[VarData, float]: - return self._provider.get_primals(vars_to_load) + return helper( + vars_to_load, self._provider.get_primals, self.has_primals, NoSolutionError + ) # 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_reduced_costs(self, vars_to_load=None): - return self._provider.get_reduced_costs(vars_to_load) + def get_reduced_costs( + self, vars_to_load: Optional[Sequence[VarData]] = None + ) -> Mapping[VarData, float]: + return helper( + vars_to_load, + self._provider.get_reduced_costs, + self.has_reduced_costs, + NoReducedCostsError, + ) def get_duals( self, cons_to_load: Optional[Sequence[ConstraintData]] = None ) -> Mapping[ConstraintData, float]: - return self._provider.get_duals(cons_to_load) + return helper( + cons_to_load, self._provider.get_duals, self.has_duals, NoDualsError + ) From 080d22b2c0b31f294939a2a055c13e4a3c9e4887 Mon Sep 17 00:00:00 2001 From: eminyouskn Date: Sat, 20 Sep 2025 13:01:37 -0400 Subject: [PATCH 28/64] Fix tolerance test --- pyomo/contrib/solver/solvers/knitro/engine.py | 4 ++++ pyomo/contrib/solver/tests/solvers/test_solvers.py | 8 ++++---- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/pyomo/contrib/solver/solvers/knitro/engine.py b/pyomo/contrib/solver/solvers/knitro/engine.py index d1a237ecd9d..30c7c17fa83 100644 --- a/pyomo/contrib/solver/solvers/knitro/engine.py +++ b/pyomo/contrib/solver/solvers/knitro/engine.py @@ -160,6 +160,10 @@ def set_obj(self, obj: ObjectiveData): def solve(self) -> int: self._register_callback() + # TODO: remove this when the tolerance test is fixed in test_solvers + self._execute(knitro.KN_set_double_param, knitro.KN_PARAM_FTOL, 1e-10) + self._execute(knitro.KN_set_double_param, knitro.KN_PARAM_OPTTOL, 1e-10) + self._execute(knitro.KN_set_double_param, knitro.KN_PARAM_XTOL, 1e-10) self._status = self._execute(knitro.KN_solve) return self._status diff --git a/pyomo/contrib/solver/tests/solvers/test_solvers.py b/pyomo/contrib/solver/tests/solvers/test_solvers.py index 3a184abb0b6..f29c5e1c482 100644 --- a/pyomo/contrib/solver/tests/solvers/test_solvers.py +++ b/pyomo/contrib/solver/tests/solvers/test_solvers.py @@ -177,7 +177,7 @@ def test_inequality( res = opt.solve(m) self.assertEqual(res.solution_status, SolutionStatus.optimal) self.assertAlmostEqual(m.x.value, 0) - self.assertAlmostEqual(m.y.value, 1, places=5) + self.assertAlmostEqual(m.y.value, 1) duals = res.solution_loader.get_duals() # the sign convention is based on the (lower, body, upper) representation of the constraint, # so we need to make sure the constraint body is what we expect @@ -200,7 +200,7 @@ def test_inequality( res = opt.solve(m) self.assertEqual(res.solution_status, SolutionStatus.optimal) self.assertAlmostEqual(m.x.value, 0) - self.assertAlmostEqual(m.y.value, 1, places=5) + self.assertAlmostEqual(m.y.value, 1) duals = res.solution_loader.get_duals() # the sign convention is based on the (lower, body, upper) representation of the constraint, # so we need to make sure the constraint body is what we expect @@ -400,7 +400,7 @@ def test_inequality_max( res = opt.solve(m) self.assertEqual(res.solution_status, SolutionStatus.optimal) self.assertAlmostEqual(m.x.value, 0) - self.assertAlmostEqual(m.y.value, 1, places=5) + self.assertAlmostEqual(m.y.value, 1) duals = res.solution_loader.get_duals() # the sign convention is based on the (lower, body, upper) representation of the constraint, # so we need to make sure the constraint body is what we expect @@ -423,7 +423,7 @@ def test_inequality_max( res = opt.solve(m) self.assertEqual(res.solution_status, SolutionStatus.optimal) self.assertAlmostEqual(m.x.value, 0) - self.assertAlmostEqual(m.y.value, 1, places=5) + self.assertAlmostEqual(m.y.value, 1) duals = res.solution_loader.get_duals() # the sign convention is based on the (lower, body, upper) representation of the constraint, # so we need to make sure the constraint body is what we expect From 7faa5d9af615672e14f09eaaeb94c9acdec313ea Mon Sep 17 00:00:00 2001 From: eminyouskn Date: Sun, 21 Sep 2025 02:58:37 -0400 Subject: [PATCH 29/64] Add hessian computation. --- pyomo/contrib/solver/solvers/knitro/engine.py | 468 ++++++++++-------- pyomo/contrib/solver/solvers/knitro/utils.py | 85 +++- .../tests/solvers/test_knitro_direct.py | 17 + 3 files changed, 350 insertions(+), 220 deletions(-) diff --git a/pyomo/contrib/solver/solvers/knitro/engine.py b/pyomo/contrib/solver/solvers/knitro/engine.py index 30c7c17fa83..e937526000c 100644 --- a/pyomo/contrib/solver/solvers/knitro/engine.py +++ b/pyomo/contrib/solver/solvers/knitro/engine.py @@ -9,15 +9,15 @@ # This software is distributed under the 3-clause BSD License. # ___________________________________________________________________________ -from collections.abc import Callable, Iterable, MutableMapping -from typing import List, Optional +from collections.abc import Iterable, Mapping, MutableMapping +from typing import List, Optional, Union +from pyomo.common.enums import ObjectiveSense from pyomo.common.numeric_types import value 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 pyomo.repn.standard_repn import generate_standard_repn from .api import knitro from .package import Package @@ -33,29 +33,41 @@ class Engine: 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] + mapping: Mapping[bool, MutableMapping[int, int]] + nonlinear_map: MutableMapping[Optional[int], NonlinearExpressionData] compute_nl_grad: bool + compute_nl_hess: bool _status: Optional[int] - def __init__(self, *, compute_nl_grad: bool = True): - self.var_map = {} - self.con_map = {} - self.obj_nl_expr = None - self.con_nl_expr_map = {} + def __init__( + self, *, compute_nl_grad: bool = True, compute_nl_hessian: bool = True + ): + # True: variables, False: constraints + self.mapping = {True: {}, False: {}} + self.nonlinear_map = {} self.compute_nl_grad = compute_nl_grad + self.compute_nl_hess = compute_nl_hessian + self._kc = None self._status = None + self.param_setters = { + knitro.KN_PARAMTYPE_INTEGER: knitro.KN_set_int_param, + knitro.KN_PARAMTYPE_FLOAT: knitro.KN_set_double_param, + knitro.KN_PARAMTYPE_STRING: knitro.KN_set_char_param, + } + def __del__(self): self.close() def renew(self): self.close() self._kc = Package.create_context() + # TODO: remove this when the tolerance test is fixed in test_solvers + self._execute(knitro.KN_set_double_param, knitro.KN_PARAM_FTOL, 1e-8) + self._execute(knitro.KN_set_double_param, knitro.KN_PARAM_OPTTOL, 1e-8) + self._execute(knitro.KN_set_double_param, knitro.KN_PARAM_XTOL, 1e-8) def close(self): if self._kc is not None: @@ -63,107 +75,24 @@ def close(self): 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 var, idx in zip(variables, idx_vars, strict=True): - self.var_map[id(var)] = idx - - var_types, fxbnds, lobnds, upbnds = {}, {}, {}, {} - for var in variables: - idx = self.var_map[id(var)] - if var.is_binary(): - var_types[idx] = knitro.KN_VARTYPE_BINARY - elif var.is_integer(): - var_types[idx] = 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[idx] = value(var.value) - else: - if var.has_lb(): - lobnds[idx] = value(var.lb) - if var.has_ub(): - upbnds[idx] = 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()) + self._add_comps(variables, is_var=True) + self._set_var_types(variables) + self._set_bnds(variables, is_var=True) 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 + self._add_comps(cons, is_var=False) + self._set_bnds(cons, is_var=False) - for con, idx in zip(cons, idx_cons, strict=True): - self.con_map[id(con)] = idx - - eqbnds, lobnds, upbnds = {}, {}, {} for con in cons: - idx = self.con_map[id(con)] - if con.equality: - eqbnds[idx] = value(con.lower) - else: - if con.has_lb(): - lobnds[idx] = value(con.lower) - if con.has_ub(): - upbnds[idx] = 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: - idx = self.con_map[id(con)] - repn = generate_standard_repn(con.body) - self._add_expr_structs_using_repn( - repn, - add_const_fn=partial(self._execute, knitro.KN_add_con_constants, idx), - add_lin_fn=partial(self._execute, knitro.KN_add_con_linear_struct, idx), - add_quad_fn=partial( - self._execute, knitro.KN_add_con_quadratic_struct, idx - ), - ) - if repn.nonlinear_expr is not None: - self.con_nl_expr_map[idx] = NonlinearExpressionData( - repn.nonlinear_expr, - repn.nonlinear_vars, - compute_grad=self.compute_nl_grad, - ) + i = self.mapping[False][id(con)] + self._add_structs(i, con.body) 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_using_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, - compute_grad=self.compute_nl_grad, - ) + self._set_obj_goal(obj.sense) + self._add_structs(None, obj.expr) def solve(self) -> int: - self._register_callback() - # TODO: remove this when the tolerance test is fixed in test_solvers - self._execute(knitro.KN_set_double_param, knitro.KN_PARAM_FTOL, 1e-10) - self._execute(knitro.KN_set_double_param, knitro.KN_PARAM_OPTTOL, 1e-10) - self._execute(knitro.KN_set_double_param, knitro.KN_PARAM_XTOL, 1e-10) + self._register_callbacks() self._status = self._execute(knitro.KN_solve) return self._status @@ -184,10 +113,10 @@ def get_solve_time(self) -> float: return self._execute(knitro.KN_get_solve_time_real) def get_var_idxs(self, variables: Iterable[VarData]) -> List[int]: - return [self.var_map[id(var)] for var in variables] + return [self.mapping[True][id(var)] for var in variables] def get_con_idxs(self, constraints: Iterable[ConstraintData]) -> List[int]: - return [self.con_map[id(con)] for con in constraints] + return [self.mapping[False][id(con)] for con in constraints] def get_var_primals(self, variables: Iterable[VarData]) -> Optional[List[float]]: idx_vars = self.get_var_idxs(variables) @@ -208,13 +137,8 @@ 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) + param_setter = self.param_setters[param_type] + self._execute(param_setter, param_id, val) def set_outlev(self, level: Optional[int] = None): if level is None: @@ -235,115 +159,235 @@ def _execute(self, api_fn, *args, **kwargs): raise RuntimeError(msg) return api_fn(self._kc, *args, **kwargs) - def _add_expr_structs_using_repn( + def _add_comps( + self, comps: Union[Iterable[VarData], Iterable[ConstraintData]], *, is_var: bool + ): + if is_var: + adder = knitro.KN_add_vars + else: + adder = knitro.KN_add_cons + idxs = self._execute(adder, len(comps)) + + if idxs is not None: + self.mapping[is_var].update(zip(map(id, comps), idxs)) + + def _parse_var_type( + self, var: VarData, i: int, var_types: MutableMapping[int, int] + ) -> int: + 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) + + def _set_var_types(self, variables: Iterable[VarData]): + var_types = {} + for var in variables: + i = self.mapping[True][id(var)] + self._parse_var_type(var, i, var_types) + self._execute(knitro.KN_set_var_types, var_types.keys(), var_types.values()) + + def _parse_var_bnds( + self, + var: VarData, + i: int, + eqbnds: MutableMapping[int, float], + lobnds: MutableMapping[int, float], + upbnds: MutableMapping[int, float], + ): + if var.fixed: + eqbnds[i] = value(var.value) + else: + if var.has_lb(): + lobnds[i] = value(var.lb) + if var.has_ub(): + upbnds[i] = value(var.ub) + + def _parse_con_bnds( 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], + con: ConstraintData, + i: int, + eqbnds: MutableMapping[int, float], + lobnds: MutableMapping[int, float], + upbnds: MutableMapping[int, float], ): + 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) + + def _set_bnds( + self, comps: Union[Iterable[VarData], Iterable[ConstraintData]], *, is_var: bool + ): + if is_var: + parser = self._parse_var_bnds + else: + parser = self._parse_con_bnds + + eqbnds, lobnds, upbnds = {}, {}, {} + for comp in comps: + i = self.mapping[is_var][id(comp)] + parser(comp, i, eqbnds, lobnds, upbnds) + + if is_var: + setters = [ + knitro.KN_set_var_fxbnds, + knitro.KN_set_var_lobnds, + knitro.KN_set_var_upbnds, + ] + else: + setters = [ + knitro.KN_set_con_eqbnds, + knitro.KN_set_con_lobnds, + knitro.KN_set_con_upbnds, + ] + for bnds, setter in zip([eqbnds, lobnds, upbnds], setters): + self._execute(setter, bnds.keys(), bnds.values()) + + def _add_structs(self, i: Optional[int], expr): + is_obj = i is None + repn = generate_standard_repn(expr) + + if is_obj: + add_constant = knitro.KN_add_obj_constant + add_linear = knitro.KN_add_obj_linear_struct + add_quadratic = knitro.KN_add_obj_quadratic_struct + else: + add_constant = knitro.KN_add_con_constants + add_linear = knitro.KN_add_con_linear_struct + add_quadratic = knitro.KN_add_con_quadratic_struct + + base_args = [] if is_obj else [i] if repn.constant is not None: - add_const_fn(repn.constant) + self._execute(add_constant, *base_args, repn.constant) if repn.linear_vars: idx_lin_vars = self.get_var_idxs(repn.linear_vars) - add_lin_fn(idx_lin_vars, list(repn.linear_coefs)) + lin_coefs = list(repn.linear_coefs) + self._execute(add_linear, *base_args, idx_lin_vars, lin_coefs) if repn.quadratic_vars: quad_vars1, quad_vars2 = zip(*repn.quadratic_vars) idx_quad_vars1 = self.get_var_idxs(quad_vars1) idx_quad_vars2 = self.get_var_idxs(quad_vars2) - add_quad_fn(idx_quad_vars1, idx_quad_vars2, list(repn.quadratic_coefs)) + quad_coefs = list(repn.quadratic_coefs) + self._execute( + add_quadratic, *base_args, idx_quad_vars1, idx_quad_vars2, quad_coefs + ) + + if repn.nonlinear_expr is not None: + self.nonlinear_map[i] = NonlinearExpressionData( + repn.nonlinear_expr, + repn.nonlinear_vars, + compute_grad=self.compute_nl_grad, + compute_hess=self.compute_nl_hess, + ) - def _build_callback_eval(self): - obj_eval = ( - self.obj_nl_expr.create_evaluator(self.var_map) - if self.obj_nl_expr is not None - else None + def _set_obj_goal(self, sense: ObjectiveSense): + obj_goal = ( + knitro.KN_OBJGOAL_MINIMIZE + if sense == ObjectiveSense.minimize + else knitro.KN_OBJGOAL_MAXIMIZE ) + self._execute(knitro.KN_set_obj_goal, obj_goal) - con_eval_map = { - i: nl_expr.create_evaluator(self.var_map) - for i, nl_expr in self.con_nl_expr_map.items() - } + def _build_callback( + self, + i: Optional[int], + expr: NonlinearExpressionData, + callback_type: int, + ): + is_obj = i is None + vmap = self.mapping[True] + if callback_type == knitro.KN_RC_EVALFC: + evaluator = expr.create_evaluator(vmap) + + def _eval(_, cb, req, res, data=None): + if req.type != knitro.KN_RC_EVALFC: + return -1 + if is_obj: + res.obj = evaluator(req.x) + else: + res.c[0] = evaluator(req.x) + return 0 + + return _eval + elif callback_type == knitro.KN_RC_EVALGA: + grad = expr.create_gradient_evaluator(vmap) + + def _grad(_, cb, req, res, data=None): + if req.type != knitro.KN_RC_EVALGA: + return -1 + if is_obj: + res.objGrad[:] = grad(req.x) + else: + res.jac[:] = grad(req.x) + return 0 + + return _grad + elif callback_type == knitro.KN_RC_EVALH: + hess = expr.create_hessian_evaluator(vmap) + + def _hess(_, cb, req, res, data=None): + if req.type != knitro.KN_RC_EVALH: + return -1 + mu = req.sigma if is_obj else req.lambda_[i] + res.hess[:] = hess(req.x, mu) + return 0 + + return _hess + + def _add_eval_callback(self, i: Optional[int], expr: NonlinearExpressionData): + func_callback = self._build_callback(i, expr, knitro.KN_RC_EVALFC) + eval_obj = i is None + idx_cons = [i] if not eval_obj else None + return self._execute( + knitro.KN_add_eval_callback, eval_obj, idx_cons, func_callback + ) - if obj_eval is None and not con_eval_map: - return None - - def _callback_eval(_, 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_eval - - def _build_callback_grad(self): - if not self.compute_nl_grad: - return None - - 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 + def _add_grad_callback( + self, i: Optional[int], expr: NonlinearExpressionData, callback + ): + idx_vars = self.get_var_idxs(expr.grad_vars) + is_obj = i is None + obj_grad_idx_vars = idx_vars if is_obj else None + jac_idx_cons = [i] * len(idx_vars) if not is_obj else None + jac_idx_vars = idx_vars if not is_obj else None + grad_callback = self._build_callback(i, expr, knitro.KN_RC_EVALGA) + self._execute( + knitro.KN_set_cb_grad, + callback, + obj_grad_idx_vars, + jac_idx_cons, + jac_idx_vars, + grad_callback, ) - con_grad_map = { - i: expr.create_gradient_evaluator(self.var_map) - for i, expr in self.con_nl_expr_map.items() - if expr.grad is not None - } - if obj_grad is None and not con_grad_map: - return 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_grad - - def _build_callback(self): - callback_eval = self._build_callback_eval() - callback_grad = self._build_callback_grad() - return callback_eval, callback_grad - - def _register_callback(self): - 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()) - cb = self._execute(knitro.KN_add_eval_callback, eval_obj, idx_cons, f) - if grad is not None: - obj_var_idxs = ( - self.get_var_idxs(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.get_var_idxs(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, - ) + def _add_hess_callback( + self, i: Optional[int], expr: NonlinearExpressionData, callback + ): + hess_vars1, hess_vars2 = zip(*expr.hess_vars) + hess_idx_vars1 = self.get_var_idxs(hess_vars1) + hess_idx_vars2 = self.get_var_idxs(hess_vars2) + hess_callback = self._build_callback(i, expr, knitro.KN_RC_EVALH) + self._execute( + knitro.KN_set_cb_hess, + callback, + hess_idx_vars1, + hess_idx_vars2, + hess_callback, + ) + + def _register_callback(self, i: Optional[int], expr: NonlinearExpressionData): + callback = self._add_eval_callback(i, expr) + if expr.grad is not None: + self._add_grad_callback(i, expr, callback) + if expr.hessian is not None: + self._add_hess_callback(i, expr, callback) + + def _register_callbacks(self): + for i, expr in self.nonlinear_map.items(): + self._register_callback(i, expr) diff --git a/pyomo/contrib/solver/solvers/knitro/utils.py b/pyomo/contrib/solver/solvers/knitro/utils.py index cae138aa5f2..7725252ab19 100644 --- a/pyomo/contrib/solver/solvers/knitro/utils.py +++ b/pyomo/contrib/solver/solvers/knitro/utils.py @@ -11,9 +11,9 @@ from collections.abc import Iterable, Mapping, MutableSet -from typing import Any, List, Optional +from typing import Any, List, Optional, Tuple -from pyomo.common.collections.component_set import ComponentSet +from pyomo.common.collections import ComponentMap, ComponentSet 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 @@ -121,6 +121,7 @@ class NonlinearExpressionData: body: Optional[Any] variables: List[VarData] grad: Optional[Mapping[VarData, Any]] + hessian: Optional[Mapping[Tuple[VarData, VarData], Any]] def __init__( self, @@ -128,13 +129,58 @@ def __init__( variables: Iterable[VarData], *, compute_grad: bool = True, + compute_hess: bool = False, ): self.body = expr self.variables = list(variables) + self.grad = None + self.hessian = None if compute_grad: - self.grad = reverse_sd(self.body) - else: - self.grad = None + self.compute_gradient() + if compute_hess: + if not compute_grad: + msg = "Hessian computation requires gradient computation." + raise ValueError(msg) + self.compute_hessian() + + @property + def grad_vars(self) -> List[VarData]: + if self.grad is None: + msg = "Gradient information is not available for this expression." + raise ValueError(msg) + return list(self.grad.keys()) + + @property + def hess_vars(self) -> List[Tuple[VarData, VarData]]: + if self.hessian is None: + msg = "Hessian information is not available for this expression." + raise ValueError(msg) + return list(self.hessian.keys()) + + def compute_gradient(self): + diff_map = reverse_sd(self.body) + variables = ComponentSet(self.variables) + self.grad = ComponentMap() + for v, expr in diff_map.items(): + if v in variables: + self.grad[v] = expr + + def compute_hessian(self): + variables = ComponentSet(self.variables) + self.hessian = ComponentMap() + for v1, expr in self.grad.items(): + diff_map = reverse_sd(expr) + for v2, diff_expr in diff_map.items(): + if v2 not in variables: + continue + var1 = v1 + var2 = v2 + if id(var1) > id(var2): + var1, var2 = var2, var1 + if (var1, var2) not in self.hessian: + self.hessian[(var1, var2)] = diff_expr + else: + self.hessian[(var1, var2)] += diff_expr def create_evaluator(self, vmap: Mapping[int, int]): """ @@ -149,7 +195,6 @@ 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]) @@ -177,10 +222,34 @@ def create_gradient_evaluator(self, vmap: Mapping[int, int]): 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 [value(expr) for expr in self.grad.values()] return _grad + + def create_hessian_evaluator(self, vmap: Mapping[int, int]): + """ + Create a callable Hessian 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[Tuple[int, int, float]]]: A function that takes a list of variable values (x) + and returns the Hessian of the expression as a list of (row, column, value) tuples. + Raises: + ValueError: If Hessian information is not available for this expression. + """ + + if self.hessian is None: + msg = "Hessian information is not available for this expression." + raise ValueError(msg) + + def _hess(x: List[float], mu: float) -> List[float]: + for var in self.variables: + i = vmap[id(var)] + var.set_value(x[i]) + return [mu * value(expr) for expr in self.hessian.values()] + + return _hess diff --git a/pyomo/contrib/solver/tests/solvers/test_knitro_direct.py b/pyomo/contrib/solver/tests/solvers/test_knitro_direct.py index 62d1ba0db22..2eeca7e042a 100644 --- a/pyomo/contrib/solver/tests/solvers/test_knitro_direct.py +++ b/pyomo/contrib/solver/tests/solvers/test_knitro_direct.py @@ -138,3 +138,20 @@ def test_solve_log(self): self.opt.solve(m) self.assertAlmostEqual(m.x.value, 0.6529186341994245) self.assertAlmostEqual(m.y.value, -0.42630274815985264) + + def test_solve_HS071(self): + m = pyo.ConcreteModel() + m.x = pyo.Var(pyo.RangeSet(1, 4), bounds=(1.0, 5.0)) + m.obj = pyo.Objective( + expr=m.x[1] * m.x[4] * (m.x[1] + m.x[2] + m.x[3]) + m.x[3], + sense=pyo.minimize, + ) + m.c1 = pyo.Constraint(expr=m.x[1] * m.x[2] * m.x[3] * m.x[4] >= 25.0) + m.c2 = pyo.Constraint( + expr=m.x[1] ** 2 + m.x[2] ** 2 + m.x[3] ** 2 + m.x[4] ** 2 == 40.0 + ) + self.opt.solve(m, solver_options={"opttol": 1e-5}) + self.assertAlmostEqual(pyo.value(m.x[1]), 1.0, 3) + self.assertAlmostEqual(pyo.value(m.x[2]), 4.743, 3) + self.assertAlmostEqual(pyo.value(m.x[3]), 3.821, 3) + self.assertAlmostEqual(pyo.value(m.x[4]), 1.379, 3) From d75e0ca233f62b5b6f6c70682b35093620d45460 Mon Sep 17 00:00:00 2001 From: eminyouskn Date: Sun, 21 Sep 2025 03:00:41 -0400 Subject: [PATCH 30/64] black format --- pyomo/contrib/solver/solvers/knitro/engine.py | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/pyomo/contrib/solver/solvers/knitro/engine.py b/pyomo/contrib/solver/solvers/knitro/engine.py index e937526000c..9ff834cbcdc 100644 --- a/pyomo/contrib/solver/solvers/knitro/engine.py +++ b/pyomo/contrib/solver/solvers/knitro/engine.py @@ -295,10 +295,7 @@ def _set_obj_goal(self, sense: ObjectiveSense): self._execute(knitro.KN_set_obj_goal, obj_goal) def _build_callback( - self, - i: Optional[int], - expr: NonlinearExpressionData, - callback_type: int, + self, i: Optional[int], expr: NonlinearExpressionData, callback_type: int ): is_obj = i is None vmap = self.mapping[True] From 27a8da36aa0df38448beeb45a1a352be39371c98 Mon Sep 17 00:00:00 2001 From: eminyouskn Date: Sun, 21 Sep 2025 03:23:55 -0400 Subject: [PATCH 31/64] Refactor --- pyomo/contrib/solver/solvers/knitro/engine.py | 98 ++++++++++--------- 1 file changed, 53 insertions(+), 45 deletions(-) diff --git a/pyomo/contrib/solver/solvers/knitro/engine.py b/pyomo/contrib/solver/solvers/knitro/engine.py index 9ff834cbcdc..408bc036247 100644 --- a/pyomo/contrib/solver/solvers/knitro/engine.py +++ b/pyomo/contrib/solver/solvers/knitro/engine.py @@ -162,12 +162,8 @@ def _execute(self, api_fn, *args, **kwargs): def _add_comps( self, comps: Union[Iterable[VarData], Iterable[ConstraintData]], *, is_var: bool ): - if is_var: - adder = knitro.KN_add_vars - else: - adder = knitro.KN_add_cons + adder = knitro.KN_add_vars if is_var else knitro.KN_add_cons idxs = self._execute(adder, len(comps)) - if idxs is not None: self.mapping[is_var].update(zip(map(id, comps), idxs)) @@ -221,39 +217,34 @@ def _parse_con_bnds( if con.has_ub(): upbnds[i] = value(con.upper) - def _set_bnds( - self, comps: Union[Iterable[VarData], Iterable[ConstraintData]], *, is_var: bool - ): - if is_var: - parser = self._parse_var_bnds - else: - parser = self._parse_con_bnds - - eqbnds, lobnds, upbnds = {}, {}, {} - for comp in comps: - i = self.mapping[is_var][id(comp)] - parser(comp, i, eqbnds, lobnds, upbnds) - + def _get_bnd_setters(self, is_var: bool): if is_var: - setters = [ + return [ knitro.KN_set_var_fxbnds, knitro.KN_set_var_lobnds, knitro.KN_set_var_upbnds, ] else: - setters = [ + return [ knitro.KN_set_con_eqbnds, knitro.KN_set_con_lobnds, knitro.KN_set_con_upbnds, ] - for bnds, setter in zip([eqbnds, lobnds, upbnds], setters): - self._execute(setter, bnds.keys(), bnds.values()) - def _add_structs(self, i: Optional[int], expr): - is_obj = i is None - repn = generate_standard_repn(expr) + def _set_bnds( + self, comps: Union[Iterable[VarData], Iterable[ConstraintData]], *, is_var: bool + ): + parser = self._parse_var_bnds if is_var else self._parse_con_bnds + bnds_data = [{}, {}, {}] # eqbnds, lobnds, upbnds + for comp in comps: + i = self.mapping[is_var][id(comp)] + parser(comp, i, *bnds_data) + setters = self._get_bnd_setters(is_var) + for bnds, setter in zip(bnds_data, setters): + self._execute(setter, bnds.keys(), bnds.values()) - if is_obj: + def _get_struct_api_funcs(self, i: Optional[int]): + if i is None: add_constant = knitro.KN_add_obj_constant add_linear = knitro.KN_add_obj_linear_struct add_quadratic = knitro.KN_add_obj_quadratic_struct @@ -261,8 +252,14 @@ def _add_structs(self, i: Optional[int], expr): add_constant = knitro.KN_add_con_constants add_linear = knitro.KN_add_con_linear_struct add_quadratic = knitro.KN_add_con_quadratic_struct + return add_constant, add_linear, add_quadratic + + def _add_structs(self, i: Optional[int], expr): + repn = generate_standard_repn(expr) + + add_constant, add_linear, add_quadratic = self._get_struct_api_funcs(i) + base_args = [] if i is None else [i] - base_args = [] if is_obj else [i] if repn.constant is not None: self._execute(add_constant, *base_args, repn.constant) if repn.linear_vars: @@ -302,38 +299,49 @@ def _build_callback( if callback_type == knitro.KN_RC_EVALFC: evaluator = expr.create_evaluator(vmap) - def _eval(_, cb, req, res, data=None): - if req.type != knitro.KN_RC_EVALFC: - return -1 - if is_obj: + if is_obj: + + def _eval(kc, cb, req, res, _): res.obj = evaluator(req.x) - else: + return 0 + + else: + + def _eval(kc, cb, req, res, _): res.c[0] = evaluator(req.x) - return 0 + return 0 return _eval elif callback_type == knitro.KN_RC_EVALGA: grad = expr.create_gradient_evaluator(vmap) - def _grad(_, cb, req, res, data=None): - if req.type != knitro.KN_RC_EVALGA: - return -1 - if is_obj: + if is_obj: + + def _grad(kc, cb, req, res, _): res.objGrad[:] = grad(req.x) - else: + return 0 + + else: + + def _grad(kc, cb, req, res, _): res.jac[:] = grad(req.x) - return 0 + return 0 return _grad elif callback_type == knitro.KN_RC_EVALH: hess = expr.create_hessian_evaluator(vmap) - def _hess(_, cb, req, res, data=None): - if req.type != knitro.KN_RC_EVALH: - return -1 - mu = req.sigma if is_obj else req.lambda_[i] - res.hess[:] = hess(req.x, mu) - return 0 + if is_obj: + + def _hess(kc, cb, req, res, _): + res.hess[:] = hess(req.x, req.sigma) + return 0 + + else: + + def _hess(kc, cb, req, res, _): + res.hess[:] = hess(req.x, req.lambda_[i]) + return 0 return _hess From 8097cc7fa2e6f4f67c27cb719ced90c99351cd07 Mon Sep 17 00:00:00 2001 From: eminyouskn Date: Sun, 21 Sep 2025 03:29:00 -0400 Subject: [PATCH 32/64] Refactor --- pyomo/contrib/solver/solvers/knitro/base.py | 8 ++++---- pyomo/contrib/solver/solvers/knitro/solution.py | 12 ++++++------ 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/pyomo/contrib/solver/solvers/knitro/base.py b/pyomo/contrib/solver/solvers/knitro/base.py index d96ff841b9e..b266e557919 100644 --- a/pyomo/contrib/solver/solvers/knitro/base.py +++ b/pyomo/contrib/solver/solvers/knitro/base.py @@ -49,7 +49,7 @@ T = TypeVar("T", bound=Union[VarData, ConstraintData]) -def helper( +def get_values( to_load: Optional[Sequence[T]], fetch_all: Callable[[], Sequence[T]], loader: Callable[[Iterable[T]], Optional[List[float]]], @@ -198,7 +198,7 @@ def get_cons(self): return self._problem.cons def get_primals(self, vars_to_load: Optional[Sequence[VarData]] = None): - return helper( + return get_values( vars_to_load, self.get_vars, self._engine.get_var_primals, @@ -207,7 +207,7 @@ def get_primals(self, vars_to_load: Optional[Sequence[VarData]] = None): ) def get_reduced_costs(self, vars_to_load: Optional[Sequence[VarData]] = None): - return helper( + return get_values( vars_to_load, self.get_vars, self._engine.get_var_duals, @@ -216,7 +216,7 @@ def get_reduced_costs(self, vars_to_load: Optional[Sequence[VarData]] = None): ) def get_duals(self, cons_to_load: Optional[Sequence[ConstraintData]] = None): - return helper( + return get_values( cons_to_load, self.get_cons, self._engine.get_con_duals, diff --git a/pyomo/contrib/solver/solvers/knitro/solution.py b/pyomo/contrib/solver/solvers/knitro/solution.py index c284990902e..48471a97609 100644 --- a/pyomo/contrib/solver/solvers/knitro/solution.py +++ b/pyomo/contrib/solver/solvers/knitro/solution.py @@ -39,13 +39,13 @@ def get_duals( T = TypeVar("T", bound=Union[VarData, ConstraintData]) -def helper( +def get_values( to_load: Optional[Sequence[T]], loader: Callable[[Optional[Sequence[T]]], Mapping[T, float]], - check: bool, + is_success: bool, error_type: Type[PyomoException], ) -> Mapping[T, float]: - if not check: + if not is_success: raise error_type() return loader(to_load) @@ -76,7 +76,7 @@ def get_number_of_solutions(self) -> int: def get_vars( self, vars_to_load: Optional[Sequence[VarData]] = None ) -> Mapping[VarData, float]: - return helper( + return get_values( vars_to_load, self._provider.get_primals, self.has_primals, NoSolutionError ) @@ -87,7 +87,7 @@ def get_primals(self, vars_to_load=None): def get_reduced_costs( self, vars_to_load: Optional[Sequence[VarData]] = None ) -> Mapping[VarData, float]: - return helper( + return get_values( vars_to_load, self._provider.get_reduced_costs, self.has_reduced_costs, @@ -97,6 +97,6 @@ def get_reduced_costs( def get_duals( self, cons_to_load: Optional[Sequence[ConstraintData]] = None ) -> Mapping[ConstraintData, float]: - return helper( + return get_values( cons_to_load, self._provider.get_duals, self.has_duals, NoDualsError ) From ea592cb118511e2f7f1adb7308fba2a1506b2bab Mon Sep 17 00:00:00 2001 From: eminyouskn Date: Sun, 21 Sep 2025 04:10:56 -0400 Subject: [PATCH 33/64] Refactor. --- pyomo/contrib/solver/solvers/knitro/base.py | 3 +- pyomo/contrib/solver/solvers/knitro/engine.py | 158 +++++++++--------- 2 files changed, 82 insertions(+), 79 deletions(-) diff --git a/pyomo/contrib/solver/solvers/knitro/base.py b/pyomo/contrib/solver/solvers/knitro/base.py index b266e557919..9ae7a01ac0a 100644 --- a/pyomo/contrib/solver/solvers/knitro/base.py +++ b/pyomo/contrib/solver/solvers/knitro/base.py @@ -131,8 +131,7 @@ def _save_var_values(self) -> None: def _restore_var_values(self) -> None: StaleFlagManager.mark_all_as_stale(delayed=True) for var in self.get_vars(): - if id(var) in self._saved_var_values: - var.set_value(self._saved_var_values[id(var)]) + var.set_value(self._saved_var_values[id(var)]) StaleFlagManager.mark_all_as_stale() @abstractmethod diff --git a/pyomo/contrib/solver/solvers/knitro/engine.py b/pyomo/contrib/solver/solvers/knitro/engine.py index 408bc036247..33e0f5657e4 100644 --- a/pyomo/contrib/solver/solvers/knitro/engine.py +++ b/pyomo/contrib/solver/solvers/knitro/engine.py @@ -45,6 +45,7 @@ def __init__( ): # True: variables, False: constraints self.mapping = {True: {}, False: {}} + # None: objective self.nonlinear_map = {} self.compute_nl_grad = compute_nl_grad self.compute_nl_hess = compute_nl_hessian @@ -77,11 +78,11 @@ def close(self): def add_vars(self, variables: Iterable[VarData]): self._add_comps(variables, is_var=True) self._set_var_types(variables) - self._set_bnds(variables, is_var=True) + self._set_comp_bnds(variables, is_var=True) def add_cons(self, cons: Iterable[ConstraintData]): self._add_comps(cons, is_var=False) - self._set_bnds(cons, is_var=False) + self._set_comp_bnds(cons, is_var=False) for con in cons: i = self.mapping[False][id(con)] @@ -113,10 +114,10 @@ def get_solve_time(self) -> float: return self._execute(knitro.KN_get_solve_time_real) def get_var_idxs(self, variables: Iterable[VarData]) -> List[int]: - return [self.mapping[True][id(var)] for var in variables] + return self._get_comp_idxs(variables, is_var=True) def get_con_idxs(self, constraints: Iterable[ConstraintData]) -> List[int]: - return [self.mapping[False][id(con)] for con in constraints] + return self._get_comp_idxs(constraints, is_var=False) def get_var_primals(self, variables: Iterable[VarData]) -> Optional[List[float]]: idx_vars = self.get_var_idxs(variables) @@ -159,6 +160,9 @@ def _execute(self, api_fn, *args, **kwargs): raise RuntimeError(msg) return api_fn(self._kc, *args, **kwargs) + def _get_comp_idxs(self, comps: Union[Iterable[VarData], Iterable[ConstraintData]], *, is_var: bool): + return [self.mapping[is_var][id(comp)] for comp in comps] + def _add_comps( self, comps: Union[Iterable[VarData], Iterable[ConstraintData]], *, is_var: bool ): @@ -231,7 +235,7 @@ def _get_bnd_setters(self, is_var: bool): knitro.KN_set_con_upbnds, ] - def _set_bnds( + def _set_comp_bnds( self, comps: Union[Iterable[VarData], Iterable[ConstraintData]], *, is_var: bool ): parser = self._parse_var_bnds if is_var else self._parse_con_bnds @@ -291,107 +295,107 @@ def _set_obj_goal(self, sense: ObjectiveSense): ) self._execute(knitro.KN_set_obj_goal, obj_goal) + def _create_evaluator(self, expr: NonlinearExpressionData, eval_type: int): + vmap = self.mapping[True] + if eval_type == knitro.KN_RC_EVALFC: + return expr.create_evaluator(vmap) + elif eval_type == knitro.KN_RC_EVALGA: + return expr.create_gradient_evaluator(vmap) + else: + return expr.create_hessian_evaluator(vmap) + def _build_callback( - self, i: Optional[int], expr: NonlinearExpressionData, callback_type: int + self, i: Optional[int], expr: NonlinearExpressionData, eval_type: int ): is_obj = i is None - vmap = self.mapping[True] - if callback_type == knitro.KN_RC_EVALFC: - evaluator = expr.create_evaluator(vmap) + func = self._create_evaluator(expr, eval_type) - if is_obj: + if is_obj: + if eval_type == knitro.KN_RC_EVALFC: - def _eval(kc, cb, req, res, _): - res.obj = evaluator(req.x) + def _callback(kc, cb, req, res, _): + res.obj = func(req.x) return 0 - else: + elif eval_type == knitro.KN_RC_EVALGA: - def _eval(kc, cb, req, res, _): - res.c[0] = evaluator(req.x) + def _callback(kc, cb, req, res, _): + res.objGrad[:] = func(req.x) return 0 - return _eval - elif callback_type == knitro.KN_RC_EVALGA: - grad = expr.create_gradient_evaluator(vmap) - - if is_obj: + else: - def _grad(kc, cb, req, res, _): - res.objGrad[:] = grad(req.x) + def _callback(kc, cb, req, res, _): + res.hess[:] = func(req.x, req.sigma) return 0 - else: + else: + if eval_type == knitro.KN_RC_EVALFC: - def _grad(kc, cb, req, res, _): - res.jac[:] = grad(req.x) + def _callback(kc, cb, req, res, _): + res.c[0] = func(req.x) return 0 - return _grad - elif callback_type == knitro.KN_RC_EVALH: - hess = expr.create_hessian_evaluator(vmap) - - if is_obj: + elif eval_type == knitro.KN_RC_EVALGA: - def _hess(kc, cb, req, res, _): - res.hess[:] = hess(req.x, req.sigma) + def _callback(kc, cb, req, res, _): + res.jac[:] = func(req.x) return 0 else: - def _hess(kc, cb, req, res, _): - res.hess[:] = hess(req.x, req.lambda_[i]) + def _callback(kc, cb, req, res, _): + res.hess[:] = func(req.x, req.lambda_[i]) return 0 - return _hess + return _callback - def _add_eval_callback(self, i: Optional[int], expr: NonlinearExpressionData): - func_callback = self._build_callback(i, expr, knitro.KN_RC_EVALFC) - eval_obj = i is None - idx_cons = [i] if not eval_obj else None - return self._execute( - knitro.KN_add_eval_callback, eval_obj, idx_cons, func_callback - ) - - def _add_grad_callback( - self, i: Optional[int], expr: NonlinearExpressionData, callback - ): - idx_vars = self.get_var_idxs(expr.grad_vars) - is_obj = i is None - obj_grad_idx_vars = idx_vars if is_obj else None - jac_idx_cons = [i] * len(idx_vars) if not is_obj else None - jac_idx_vars = idx_vars if not is_obj else None - grad_callback = self._build_callback(i, expr, knitro.KN_RC_EVALGA) - self._execute( - knitro.KN_set_cb_grad, - callback, - obj_grad_idx_vars, - jac_idx_cons, - jac_idx_vars, - grad_callback, - ) - - def _add_hess_callback( - self, i: Optional[int], expr: NonlinearExpressionData, callback + def _add_callback( + self, + i: Optional[int], + expr: NonlinearExpressionData, + eval_type: int, + callback=None, ): - hess_vars1, hess_vars2 = zip(*expr.hess_vars) - hess_idx_vars1 = self.get_var_idxs(hess_vars1) - hess_idx_vars2 = self.get_var_idxs(hess_vars2) - hess_callback = self._build_callback(i, expr, knitro.KN_RC_EVALH) - self._execute( - knitro.KN_set_cb_hess, - callback, - hess_idx_vars1, - hess_idx_vars2, - hess_callback, - ) + func_callback = self._build_callback(i, expr, eval_type) + if eval_type == knitro.KN_RC_EVALFC: + eval_obj = i is None + idx_cons = [i] if not eval_obj else None + return self._execute( + knitro.KN_add_eval_callback, eval_obj, idx_cons, func_callback + ) + elif eval_type == knitro.KN_RC_EVALGA: + idx_vars = self.get_var_idxs(expr.grad_vars) + is_obj = i is None + obj_grad_idx_vars = idx_vars if is_obj else None + jac_idx_cons = [i] * len(idx_vars) if not is_obj else None + jac_idx_vars = idx_vars if not is_obj else None + return self._execute( + knitro.KN_set_cb_grad, + callback, + obj_grad_idx_vars, + jac_idx_cons, + jac_idx_vars, + func_callback, + ) + else: + hess_vars1, hess_vars2 = zip(*expr.hess_vars) + hess_idx_vars1 = self.get_var_idxs(hess_vars1) + hess_idx_vars2 = self.get_var_idxs(hess_vars2) + return self._execute( + knitro.KN_set_cb_hess, + callback, + hess_idx_vars1, + hess_idx_vars2, + func_callback, + ) def _register_callback(self, i: Optional[int], expr: NonlinearExpressionData): - callback = self._add_eval_callback(i, expr) + callback = self._add_callback(i, expr, knitro.KN_RC_EVALFC) if expr.grad is not None: - self._add_grad_callback(i, expr, callback) + self._add_callback(i, expr, knitro.KN_RC_EVALGA, callback) if expr.hessian is not None: - self._add_hess_callback(i, expr, callback) + self._add_callback(i, expr, knitro.KN_RC_EVALH, callback) def _register_callbacks(self): for i, expr in self.nonlinear_map.items(): From eeb623289693fdc038bcee64fa5cc78cf1fb5c49 Mon Sep 17 00:00:00 2001 From: eminyouskn Date: Sun, 21 Sep 2025 04:12:23 -0400 Subject: [PATCH 34/64] Refactor. --- pyomo/contrib/solver/solvers/knitro/engine.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/pyomo/contrib/solver/solvers/knitro/engine.py b/pyomo/contrib/solver/solvers/knitro/engine.py index 33e0f5657e4..b1e44857c9c 100644 --- a/pyomo/contrib/solver/solvers/knitro/engine.py +++ b/pyomo/contrib/solver/solvers/knitro/engine.py @@ -65,10 +65,11 @@ def __del__(self): def renew(self): self.close() self._kc = Package.create_context() - # TODO: remove this when the tolerance test is fixed in test_solvers - self._execute(knitro.KN_set_double_param, knitro.KN_PARAM_FTOL, 1e-8) - self._execute(knitro.KN_set_double_param, knitro.KN_PARAM_OPTTOL, 1e-8) - self._execute(knitro.KN_set_double_param, knitro.KN_PARAM_XTOL, 1e-8) + # TODO: remove this when the tolerance tests are fixed in test_solvers + tol = 1e-8 + self._execute(knitro.KN_set_double_param, knitro.KN_PARAM_FTOL, tol) + self._execute(knitro.KN_set_double_param, knitro.KN_PARAM_OPTTOL, tol) + self._execute(knitro.KN_set_double_param, knitro.KN_PARAM_XTOL, tol) def close(self): if self._kc is not None: From 586d392e1b129a58644e7d011806d443b6c6ef17 Mon Sep 17 00:00:00 2001 From: eminyouskn Date: Sun, 21 Sep 2025 04:17:34 -0400 Subject: [PATCH 35/64] Refactor --- pyomo/contrib/solver/solvers/knitro/engine.py | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/pyomo/contrib/solver/solvers/knitro/engine.py b/pyomo/contrib/solver/solvers/knitro/engine.py index b1e44857c9c..c63ff5985ae 100644 --- a/pyomo/contrib/solver/solvers/knitro/engine.py +++ b/pyomo/contrib/solver/solvers/knitro/engine.py @@ -222,7 +222,7 @@ def _parse_con_bnds( if con.has_ub(): upbnds[i] = value(con.upper) - def _get_bnd_setters(self, is_var: bool): + def _get_bnd_setter_api_funcs(self, is_var: bool): if is_var: return [ knitro.KN_set_var_fxbnds, @@ -244,7 +244,7 @@ def _set_comp_bnds( for comp in comps: i = self.mapping[is_var][id(comp)] parser(comp, i, *bnds_data) - setters = self._get_bnd_setters(is_var) + setters = self._get_bnd_setter_api_funcs(is_var) for bnds, setter in zip(bnds_data, setters): self._execute(setter, bnds.keys(), bnds.values()) @@ -299,11 +299,12 @@ def _set_obj_goal(self, sense: ObjectiveSense): def _create_evaluator(self, expr: NonlinearExpressionData, eval_type: int): vmap = self.mapping[True] if eval_type == knitro.KN_RC_EVALFC: - return expr.create_evaluator(vmap) + creator = expr.create_evaluator elif eval_type == knitro.KN_RC_EVALGA: - return expr.create_gradient_evaluator(vmap) + creator = expr.create_gradient_evaluator else: - return expr.create_hessian_evaluator(vmap) + creator = expr.create_hessian_evaluator + return creator(vmap) def _build_callback( self, i: Optional[int], expr: NonlinearExpressionData, eval_type: int From bf72313086534073c352ad8a9b8b50e706b19417 Mon Sep 17 00:00:00 2001 From: eminyouskn Date: Sun, 21 Sep 2025 04:35:55 -0400 Subject: [PATCH 36/64] Refactor --- pyomo/contrib/solver/solvers/knitro/engine.py | 115 +++++++++--------- pyomo/contrib/solver/solvers/knitro/utils.py | 10 +- 2 files changed, 60 insertions(+), 65 deletions(-) diff --git a/pyomo/contrib/solver/solvers/knitro/engine.py b/pyomo/contrib/solver/solvers/knitro/engine.py index c63ff5985ae..e51dd644c9a 100644 --- a/pyomo/contrib/solver/solvers/knitro/engine.py +++ b/pyomo/contrib/solver/solvers/knitro/engine.py @@ -35,30 +35,22 @@ class Engine: mapping: Mapping[bool, MutableMapping[int, int]] nonlinear_map: MutableMapping[Optional[int], NonlinearExpressionData] - compute_nl_grad: bool - compute_nl_hess: bool + differentiation_order: int _status: Optional[int] - def __init__( - self, *, compute_nl_grad: bool = True, compute_nl_hessian: bool = True - ): + def __init__(self, *, differentiation_order: int = 0): # True: variables, False: constraints self.mapping = {True: {}, False: {}} # None: objective self.nonlinear_map = {} - self.compute_nl_grad = compute_nl_grad - self.compute_nl_hess = compute_nl_hessian + self.differentiation_order = differentiation_order + # Saving the KNITRO context self._kc = None + # KNITRO status after solve self._status = None - self.param_setters = { - knitro.KN_PARAMTYPE_INTEGER: knitro.KN_set_int_param, - knitro.KN_PARAMTYPE_FLOAT: knitro.KN_set_double_param, - knitro.KN_PARAMTYPE_STRING: knitro.KN_set_char_param, - } - def __del__(self): self.close() @@ -139,7 +131,7 @@ 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) - param_setter = self.param_setters[param_type] + param_setter = self._get_param_setter_api_func(param_type) self._execute(param_setter, param_id, val) def set_outlev(self, level: Optional[int] = None): @@ -161,7 +153,17 @@ def _execute(self, api_fn, *args, **kwargs): raise RuntimeError(msg) return api_fn(self._kc, *args, **kwargs) - def _get_comp_idxs(self, comps: Union[Iterable[VarData], Iterable[ConstraintData]], *, is_var: bool): + def _get_param_setter_api_func(self, param_type: int): + if param_type == knitro.KN_PARAMTYPE_INTEGER: + return knitro.KN_set_int_param + elif param_type == knitro.KN_PARAMTYPE_FLOAT: + return knitro.KN_set_double_param + else: + return knitro.KN_set_char_param + + def _get_comp_idxs( + self, comps: Union[Iterable[VarData], Iterable[ConstraintData]], *, is_var: bool + ): return [self.mapping[is_var][id(comp)] for comp in comps] def _add_comps( @@ -284,8 +286,7 @@ def _add_structs(self, i: Optional[int], expr): self.nonlinear_map[i] = NonlinearExpressionData( repn.nonlinear_expr, repn.nonlinear_vars, - compute_grad=self.compute_nl_grad, - compute_hess=self.compute_nl_hess, + differentiation_order=self.differentiation_order, ) def _set_obj_goal(self, sense: ObjectiveSense): @@ -298,12 +299,12 @@ def _set_obj_goal(self, sense: ObjectiveSense): def _create_evaluator(self, expr: NonlinearExpressionData, eval_type: int): vmap = self.mapping[True] - if eval_type == knitro.KN_RC_EVALFC: - creator = expr.create_evaluator + if eval_type == knitro.KN_RC_EVALH: + creator = expr.create_hessian_evaluator elif eval_type == knitro.KN_RC_EVALGA: creator = expr.create_gradient_evaluator else: - creator = expr.create_hessian_evaluator + creator = expr.create_evaluator return creator(vmap) def _build_callback( @@ -312,43 +313,41 @@ def _build_callback( is_obj = i is None func = self._create_evaluator(expr, eval_type) - if is_obj: - if eval_type == knitro.KN_RC_EVALFC: + if is_obj and eval_type == knitro.KN_RC_EVALH: - def _callback(kc, cb, req, res, _): - res.obj = func(req.x) - return 0 + def _callback(kc, cb, req, res, _): + res.hess[:] = func(req.x, req.sigma) + return 0 - elif eval_type == knitro.KN_RC_EVALGA: + elif is_obj and eval_type == knitro.KN_RC_EVALGA: - def _callback(kc, cb, req, res, _): - res.objGrad[:] = func(req.x) - return 0 + def _callback(kc, cb, req, res, _): + res.objGrad[:] = func(req.x) + return 0 - else: + elif is_obj: - def _callback(kc, cb, req, res, _): - res.hess[:] = func(req.x, req.sigma) - return 0 + def _callback(kc, cb, req, res, _): + res.obj = func(req.x) + return 0 - else: - if eval_type == knitro.KN_RC_EVALFC: + elif eval_type == knitro.KN_RC_EVALH: - def _callback(kc, cb, req, res, _): - res.c[0] = func(req.x) - return 0 + def _callback(kc, cb, req, res, _): + res.hess[:] = func(req.x, req.lambda_[i]) + return 0 - elif eval_type == knitro.KN_RC_EVALGA: + elif eval_type == knitro.KN_RC_EVALGA: - def _callback(kc, cb, req, res, _): - res.jac[:] = func(req.x) - return 0 + def _callback(kc, cb, req, res, _): + res.jac[:] = func(req.x) + return 0 - else: + else: - def _callback(kc, cb, req, res, _): - res.hess[:] = func(req.x, req.lambda_[i]) - return 0 + def _callback(kc, cb, req, res, _): + res.c[:] = [func(req.x)] + return 0 return _callback @@ -360,11 +359,16 @@ def _add_callback( callback=None, ): func_callback = self._build_callback(i, expr, eval_type) - if eval_type == knitro.KN_RC_EVALFC: - eval_obj = i is None - idx_cons = [i] if not eval_obj else None + if eval_type == knitro.KN_RC_EVALH: + hess_vars1, hess_vars2 = zip(*expr.hess_vars) + hess_idx_vars1 = self.get_var_idxs(hess_vars1) + hess_idx_vars2 = self.get_var_idxs(hess_vars2) return self._execute( - knitro.KN_add_eval_callback, eval_obj, idx_cons, func_callback + knitro.KN_set_cb_hess, + callback, + hess_idx_vars1, + hess_idx_vars2, + func_callback, ) elif eval_type == knitro.KN_RC_EVALGA: idx_vars = self.get_var_idxs(expr.grad_vars) @@ -381,15 +385,10 @@ def _add_callback( func_callback, ) else: - hess_vars1, hess_vars2 = zip(*expr.hess_vars) - hess_idx_vars1 = self.get_var_idxs(hess_vars1) - hess_idx_vars2 = self.get_var_idxs(hess_vars2) + eval_obj = i is None + idx_cons = [i] if not eval_obj else None return self._execute( - knitro.KN_set_cb_hess, - callback, - hess_idx_vars1, - hess_idx_vars2, - func_callback, + knitro.KN_add_eval_callback, eval_obj, idx_cons, func_callback ) def _register_callback(self, i: Optional[int], expr: NonlinearExpressionData): diff --git a/pyomo/contrib/solver/solvers/knitro/utils.py b/pyomo/contrib/solver/solvers/knitro/utils.py index 7725252ab19..5ef3dd33a8a 100644 --- a/pyomo/contrib/solver/solvers/knitro/utils.py +++ b/pyomo/contrib/solver/solvers/knitro/utils.py @@ -128,19 +128,15 @@ def __init__( expr: Optional[Any], variables: Iterable[VarData], *, - compute_grad: bool = True, - compute_hess: bool = False, + differentiation_order: int = 0, ): self.body = expr self.variables = list(variables) self.grad = None self.hessian = None - if compute_grad: + if differentiation_order > 0: self.compute_gradient() - if compute_hess: - if not compute_grad: - msg = "Hessian computation requires gradient computation." - raise ValueError(msg) + if differentiation_order > 1: self.compute_hessian() @property From 457b1991cd077b9cca172b70ae4fbcf9067c00b8 Mon Sep 17 00:00:00 2001 From: eminyouskn Date: Sun, 21 Sep 2025 18:55:26 -0400 Subject: [PATCH 37/64] Refactor --- pyomo/contrib/solver/solvers/knitro/base.py | 75 +-- pyomo/contrib/solver/solvers/knitro/engine.py | 509 ++++++++++-------- .../contrib/solver/solvers/knitro/solution.py | 72 +-- pyomo/contrib/solver/solvers/knitro/utils.py | 175 ++++-- 4 files changed, 481 insertions(+), 350 deletions(-) diff --git a/pyomo/contrib/solver/solvers/knitro/base.py b/pyomo/contrib/solver/solvers/knitro/base.py index 9ae7a01ac0a..9f7e8a6fc36 100644 --- a/pyomo/contrib/solver/solvers/knitro/base.py +++ b/pyomo/contrib/solver/solvers/knitro/base.py @@ -11,13 +11,13 @@ from abc import abstractmethod -from collections.abc import Callable, Iterable, Mapping, Sequence +from collections.abc import Mapping, Sequence from datetime import datetime, timezone from io import StringIO -from typing import List, Optional, Type, TypeVar, Union +from typing import Optional, Type, Union from pyomo.common.collections import ComponentMap -from pyomo.common.errors import ApplicationError, PyomoException +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 @@ -29,10 +29,7 @@ ) from pyomo.contrib.solver.common.util import ( IncompatibleModelError, - NoDualsError, NoOptimalSolutionError, - NoReducedCostsError, - NoSolutionError, ) from pyomo.core.base.block import BlockData from pyomo.core.base.constraint import ConstraintData @@ -46,24 +43,6 @@ from .utils import Problem from .solution import SolutionLoader, SolutionProvider -T = TypeVar("T", bound=Union[VarData, ConstraintData]) - - -def get_values( - to_load: Optional[Sequence[T]], - fetch_all: Callable[[], Sequence[T]], - loader: Callable[[Iterable[T]], Optional[List[float]]], - error_type: Type[PyomoException], - flip_sign: bool = False, -) -> Mapping[T, float]: - if to_load is None: - to_load = fetch_all() - x = loader(to_load) - if x is None: - raise error_type() - sign = -1.0 if flip_sign else 1.0 - return ComponentMap([(k, sign * x[i]) for i, k in enumerate(to_load)]) - class SolverBase(SolutionProvider, PackageChecker, base.SolverBase): CONFIG = Config() @@ -196,32 +175,28 @@ def get_objectives(self): def get_cons(self): return self._problem.cons - def get_primals(self, vars_to_load: Optional[Sequence[VarData]] = None): - return get_values( - vars_to_load, - self.get_vars, - self._engine.get_var_primals, - NoSolutionError, - flip_sign=False, - ) - - def get_reduced_costs(self, vars_to_load: Optional[Sequence[VarData]] = None): - return get_values( - vars_to_load, - self.get_vars, - self._engine.get_var_duals, - NoReducedCostsError, - flip_sign=True, - ) - - def get_duals(self, cons_to_load: Optional[Sequence[ConstraintData]] = None): - return get_values( - cons_to_load, - self.get_cons, - self._engine.get_con_duals, - NoDualsError, - flip_sign=True, - ) + def get_fetch_all(self, load_type: Type[Union[VarData, ConstraintData]]): + if load_type is VarData: + return self.get_vars + elif load_type is ConstraintData: + return self.get_cons + + def get_values( + self, + load_type: Type[Union[VarData, ConstraintData]], + to_load: Optional[Union[Sequence[VarData], Sequence[ConstraintData]]], + *, + is_dual: bool, + ): + fetch_all = self.get_fetch_all(load_type) + if to_load is None: + to_load = fetch_all() + x = self._engine.get_values(load_type, to_load, is_dual=is_dual) + if x is None: + error_type = self.get_error_type(load_type, is_dual=is_dual) + raise error_type() + sign = -1.0 if is_dual else 1.0 + return ComponentMap([(k, sign * x[i]) for i, k in enumerate(to_load)]) def get_num_solutions(self): return self._engine.get_num_solutions() diff --git a/pyomo/contrib/solver/solvers/knitro/engine.py b/pyomo/contrib/solver/solvers/knitro/engine.py index e51dd644c9a..8e72505d91a 100644 --- a/pyomo/contrib/solver/solvers/knitro/engine.py +++ b/pyomo/contrib/solver/solvers/knitro/engine.py @@ -1,18 +1,7 @@ -# ___________________________________________________________________________ -# -# 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, MutableMapping -from typing import List, Optional, Union +from typing import List, Optional, Type, Union -from pyomo.common.enums import ObjectiveSense +from pyomo.common.enums import Enum, ObjectiveSense from pyomo.common.numeric_types import value from pyomo.core.base.constraint import ConstraintData from pyomo.core.base.objective import ObjectiveData @@ -33,15 +22,15 @@ class Engine: setting options, solving, and freeing the context. """ - mapping: Mapping[bool, MutableMapping[int, int]] + mapping: Mapping[Type[Union[VarData, ConstraintData]], MutableMapping[int, int]] nonlinear_map: MutableMapping[Optional[int], NonlinearExpressionData] differentiation_order: int _status: Optional[int] - def __init__(self, *, differentiation_order: int = 0): + def __init__(self, *, differentiation_order: int = 2): # True: variables, False: constraints - self.mapping = {True: {}, False: {}} + self.mapping = {VarData: {}, ConstraintData: {}} # None: objective self.nonlinear_map = {} self.differentiation_order = differentiation_order @@ -51,6 +40,8 @@ def __init__(self, *, differentiation_order: int = 0): # KNITRO status after solve self._status = None + # ============= LIFECYCLE MANAGEMENT ============= + def __del__(self): self.close() @@ -59,37 +50,69 @@ def renew(self): self._kc = Package.create_context() # TODO: remove this when the tolerance tests are fixed in test_solvers tol = 1e-8 - self._execute(knitro.KN_set_double_param, knitro.KN_PARAM_FTOL, tol) - self._execute(knitro.KN_set_double_param, knitro.KN_PARAM_OPTTOL, tol) - self._execute(knitro.KN_set_double_param, knitro.KN_PARAM_XTOL, tol) + self.set_options(ftol=tol, opttol=tol, xtol=tol) def close(self): if self._kc is not None: self._execute(knitro.KN_free) self._kc = None + # ============= PROBLEM BUILDING ================= + def add_vars(self, variables: Iterable[VarData]): - self._add_comps(variables, is_var=True) + self._add_items(VarData, variables) self._set_var_types(variables) - self._set_comp_bnds(variables, is_var=True) + self._set_bnds(VarData, variables) def add_cons(self, cons: Iterable[ConstraintData]): - self._add_comps(cons, is_var=False) - self._set_comp_bnds(cons, is_var=False) + self._add_items(ConstraintData, cons) + self._set_bnds(ConstraintData, cons) for con in cons: - i = self.mapping[False][id(con)] + i = self.mapping[ConstraintData][id(con)] self._add_structs(i, con.body) def set_obj(self, obj: ObjectiveData): self._set_obj_goal(obj.sense) self._add_structs(None, obj.expr) + # ============= CONFIGURATION =================== + + 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) + func = self.api_set_param(param_type) + self._execute(func, param_id, val) + + def set_outlev(self, level: Optional[int] = None): + if level is None: + level = 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) + + # ============= SOLVING ========================= + def solve(self) -> int: self._register_callbacks() self._status = self._execute(knitro.KN_solve) return self._status + # ============= INDEX RETRIEVAL ================= + + def get_idx_vars(self, variables: Iterable[VarData]) -> List[int]: + return self._get_idxs(VarData, variables) + + def get_idx_cons(self, constraints: Iterable[ConstraintData]) -> List[int]: + return self._get_idxs(ConstraintData, constraints) + + # ============= SOLUTION RETRIEVAL ============== + def get_status(self) -> int: if self._status is None: msg = "Solver has not been run yet. Since the solver has not been executed, no status is available." @@ -106,73 +129,57 @@ def get_num_solutions(self) -> int: def get_solve_time(self) -> float: return self._execute(knitro.KN_get_solve_time_real) - def get_var_idxs(self, variables: Iterable[VarData]) -> List[int]: - return self._get_comp_idxs(variables, is_var=True) - - def get_con_idxs(self, constraints: Iterable[ConstraintData]) -> List[int]: - return self._get_comp_idxs(constraints, is_var=False) - - def get_var_primals(self, variables: Iterable[VarData]) -> Optional[List[float]]: - idx_vars = self.get_var_idxs(variables) - return self._execute(knitro.KN_get_var_primal_values, idx_vars) - - def get_var_duals(self, variables: Iterable[VarData]) -> Optional[List[float]]: - idx_vars = self.get_var_idxs(variables) - return self._execute(knitro.KN_get_var_dual_values, idx_vars) - - def get_con_duals(self, cons: Iterable[ConstraintData]) -> Optional[List[float]]: - idx_cons = self.get_con_idxs(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) - param_setter = self._get_param_setter_api_func(param_type) - self._execute(param_setter, param_id, val) - - def set_outlev(self, level: Optional[int] = None): - if level is None: - level = 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) + def get_values( + self, + item_type: Type[Union[VarData, ConstraintData]], + items: Union[Iterable[VarData], Iterable[ConstraintData]], + *, + is_dual: bool, + ): + func = self.api_get_values(item_type, is_dual) + idxs = self._get_idxs(item_type, items) + return self._execute(func, idxs) - # ----------------- Private methods ------------------------- + # ============= PRIVATE UTILITIES =============== - def _execute(self, api_fn, *args, **kwargs): + def _execute(self, api_func, *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) + return api_func(self._kc, *args, **kwargs) - def _get_param_setter_api_func(self, param_type: int): - if param_type == knitro.KN_PARAMTYPE_INTEGER: - return knitro.KN_set_int_param - elif param_type == knitro.KN_PARAMTYPE_FLOAT: - return knitro.KN_set_double_param - else: - return knitro.KN_set_char_param + # ======== PRIVATE COMPONENT MANAGEMENT ========= - def _get_comp_idxs( - self, comps: Union[Iterable[VarData], Iterable[ConstraintData]], *, is_var: bool + def _get_idxs( + self, + item_type: Type[Union[VarData, ConstraintData]], + items: Union[Iterable[VarData], Iterable[ConstraintData]], ): - return [self.mapping[is_var][id(comp)] for comp in comps] + imap = self.mapping[item_type] + return [imap[id(item)] for item in items] - def _add_comps( - self, comps: Union[Iterable[VarData], Iterable[ConstraintData]], *, is_var: bool + def _add_items( + self, + item_type: Type[Union[VarData, ConstraintData]], + items: Union[Iterable[VarData], Iterable[ConstraintData]], ): - adder = knitro.KN_add_vars if is_var else knitro.KN_add_cons - idxs = self._execute(adder, len(comps)) + func = self.api_add_items(item_type) + idxs = self._execute(func, len(items)) if idxs is not None: - self.mapping[is_var].update(zip(map(id, comps), idxs)) + self.mapping[item_type].update(zip(map(id, items), idxs)) + + # ====== PRIVATE VARIABLE TYPE HANDLING ========= + + def _set_var_types(self, variables: Iterable[VarData]): + var_types = {} + func = knitro.KN_set_var_types + for var in variables: + i = self.mapping[VarData][id(var)] + self._parse_var_type(var, i, var_types) + self._execute(func, var_types.keys(), var_types.values()) def _parse_var_type( self, var: VarData, i: int, var_types: MutableMapping[int, int] @@ -181,106 +188,109 @@ def _parse_var_type( var_types[i] = knitro.KN_VARTYPE_BINARY elif var.is_integer(): var_types[i] = knitro.KN_VARTYPE_INTEGER - elif not var.is_continuous(): + elif var.is_continuous(): + var_types[i] = knitro.KN_VARTYPE_CONTINUOUS + else: msg = f"Unknown variable type for variable {var.name}." raise ValueError(msg) - def _set_var_types(self, variables: Iterable[VarData]): - var_types = {} - for var in variables: - i = self.mapping[True][id(var)] - self._parse_var_type(var, i, var_types) - self._execute(knitro.KN_set_var_types, var_types.keys(), var_types.values()) + # ========== PRIVATE BOUNDS HANDLING ============ + + class _BndType(Enum): + EQ = 0 + LO = 1 + UP = 2 + + def _get_parse_bnds(self, item_type: Type[Union[VarData, ConstraintData]]): + if item_type is VarData: + return self._parse_var_bnds + elif item_type is ConstraintData: + return self._parse_con_bnds + + def _set_bnds( + self, + item_type: Type[Union[VarData, ConstraintData]], + items: Union[Iterable[VarData], Iterable[ConstraintData]], + ): + parse = self._get_parse_bnds(item_type) + bnds_map = {bnd_type: {} for bnd_type in self._BndType} + imap = self.mapping[item_type] + for item in items: + parse(item, imap[id(item)], bnds_map) + + for bnd_type, bnds in bnds_map.items(): + if bnds: + func = self.api_set_bnds(item_type, bnd_type) + self._execute(func, bnds.keys(), bnds.values()) def _parse_var_bnds( self, var: VarData, i: int, - eqbnds: MutableMapping[int, float], - lobnds: MutableMapping[int, float], - upbnds: MutableMapping[int, float], + bnds_map: Mapping[_BndType, MutableMapping[int, float]], ): if var.fixed: - eqbnds[i] = value(var.value) + bnds_map[Engine._BndType.EQ][i] = value(var.value) else: if var.has_lb(): - lobnds[i] = value(var.lb) + bnds_map[Engine._BndType.LO][i] = value(var.lb) if var.has_ub(): - upbnds[i] = value(var.ub) + bnds_map[Engine._BndType.UP][i] = value(var.ub) def _parse_con_bnds( self, con: ConstraintData, i: int, - eqbnds: MutableMapping[int, float], - lobnds: MutableMapping[int, float], - upbnds: MutableMapping[int, float], + bnds_map: Mapping[_BndType, MutableMapping[int, float]], ): if con.equality: - eqbnds[i] = value(con.lower) + bnds_map[Engine._BndType.EQ][i] = value(con.lower) else: if con.has_lb(): - lobnds[i] = value(con.lower) + bnds_map[Engine._BndType.LO][i] = value(con.lower) if con.has_ub(): - upbnds[i] = value(con.upper) - - def _get_bnd_setter_api_funcs(self, is_var: bool): - if is_var: - return [ - knitro.KN_set_var_fxbnds, - knitro.KN_set_var_lobnds, - knitro.KN_set_var_upbnds, - ] - else: - return [ - knitro.KN_set_con_eqbnds, - knitro.KN_set_con_lobnds, - knitro.KN_set_con_upbnds, - ] - - def _set_comp_bnds( - self, comps: Union[Iterable[VarData], Iterable[ConstraintData]], *, is_var: bool - ): - parser = self._parse_var_bnds if is_var else self._parse_con_bnds - bnds_data = [{}, {}, {}] # eqbnds, lobnds, upbnds - for comp in comps: - i = self.mapping[is_var][id(comp)] - parser(comp, i, *bnds_data) - setters = self._get_bnd_setter_api_funcs(is_var) - for bnds, setter in zip(bnds_data, setters): - self._execute(setter, bnds.keys(), bnds.values()) - - def _get_struct_api_funcs(self, i: Optional[int]): - if i is None: - add_constant = knitro.KN_add_obj_constant - add_linear = knitro.KN_add_obj_linear_struct - add_quadratic = knitro.KN_add_obj_quadratic_struct - else: - add_constant = knitro.KN_add_con_constants - add_linear = knitro.KN_add_con_linear_struct - add_quadratic = knitro.KN_add_con_quadratic_struct - return add_constant, add_linear, add_quadratic + bnds_map[Engine._BndType.UP][i] = value(con.upper) + + # ===== PRIVATE OBJECTIVE GOAL HANDLING ========= + + def _set_obj_goal(self, sense: ObjectiveSense): + obj_goal = ( + knitro.KN_OBJGOAL_MINIMIZE + if sense == ObjectiveSense.minimize + else knitro.KN_OBJGOAL_MAXIMIZE + ) + self._execute(knitro.KN_set_obj_goal, obj_goal) + + # ======= PRIVATE STRUCTURE BUILDING ============ def _add_structs(self, i: Optional[int], expr): repn = generate_standard_repn(expr) - add_constant, add_linear, add_quadratic = self._get_struct_api_funcs(i) - base_args = [] if i is None else [i] + is_obj = i is None + base_args = () if is_obj else (i,) + funcs, args_seq = [], [] if repn.constant is not None: - self._execute(add_constant, *base_args, repn.constant) + func = self.api_add_constant(is_obj) + funcs.append(func) + args_seq.append((repn.constant,)) if repn.linear_vars: - idx_lin_vars = self.get_var_idxs(repn.linear_vars) + func = self.api_add_linear_struct(is_obj) + idx_lin_vars = self.get_idx_vars(repn.linear_vars) lin_coefs = list(repn.linear_coefs) - self._execute(add_linear, *base_args, idx_lin_vars, lin_coefs) + funcs.append(func) + args_seq.append((idx_lin_vars, lin_coefs)) if repn.quadratic_vars: + func = self.api_add_quadratic_struct(is_obj) quad_vars1, quad_vars2 = zip(*repn.quadratic_vars) - idx_quad_vars1 = self.get_var_idxs(quad_vars1) - idx_quad_vars2 = self.get_var_idxs(quad_vars2) + idx_quad_vars1 = self.get_idx_vars(quad_vars1) + idx_quad_vars2 = self.get_idx_vars(quad_vars2) quad_coefs = list(repn.quadratic_coefs) - self._execute( - add_quadratic, *base_args, idx_quad_vars1, idx_quad_vars2, quad_coefs - ) + funcs.append(func) + args_seq.append((idx_quad_vars1, idx_quad_vars2, quad_coefs)) + + for func, args in zip(funcs, args_seq): + self._execute(func, *base_args, *args) if repn.nonlinear_expr is not None: self.nonlinear_map[i] = NonlinearExpressionData( @@ -289,115 +299,178 @@ def _add_structs(self, i: Optional[int], expr): differentiation_order=self.differentiation_order, ) - def _set_obj_goal(self, sense: ObjectiveSense): - obj_goal = ( - knitro.KN_OBJGOAL_MINIMIZE - if sense == ObjectiveSense.minimize - else knitro.KN_OBJGOAL_MAXIMIZE - ) - self._execute(knitro.KN_set_obj_goal, obj_goal) + # ======= PRIVATE CALLBACK HANDLING ============= + + def _register_callbacks(self): + for i, expr in self.nonlinear_map.items(): + self._register_callback(i, expr) + + def _register_callback(self, i: Optional[int], expr: NonlinearExpressionData): + callback = self._add_callback(i, expr, knitro.KN_RC_EVALFC) + if expr.grad is not None: + self._add_callback(i, expr, knitro.KN_RC_EVALGA, callback) + if expr.hess is not None: + self._add_callback(i, expr, knitro.KN_RC_EVALH, callback) + + def _add_callback( + self, + i: Optional[int], + expr: NonlinearExpressionData, + eval_type: int, + callback=None, + ): + func = self.api_add_callback(eval_type) + func_callback = self._build_callback(i, expr, eval_type) + + args = () + if callback is not None: + args += (callback,) - def _create_evaluator(self, expr: NonlinearExpressionData, eval_type: int): - vmap = self.mapping[True] if eval_type == knitro.KN_RC_EVALH: - creator = expr.create_hessian_evaluator + hess_vars1, hess_vars2 = zip(*expr.hess_vars) + hess_idx_vars1 = self.get_idx_vars(hess_vars1) + hess_idx_vars2 = self.get_idx_vars(hess_vars2) + args += (hess_idx_vars1, hess_idx_vars2) elif eval_type == knitro.KN_RC_EVALGA: - creator = expr.create_gradient_evaluator - else: - creator = expr.create_evaluator - return creator(vmap) + idx_vars = self.get_idx_vars(expr.grad_vars) + is_obj = i is None + obj_grad_idx_vars = idx_vars if is_obj else None + jac_idx_cons = [i] * len(idx_vars) if not is_obj else None + jac_idx_vars = idx_vars if not is_obj else None + args += (obj_grad_idx_vars, jac_idx_cons, jac_idx_vars) + elif eval_type == knitro.KN_RC_EVALFC: + eval_obj = i is None + idx_cons = [i] if not eval_obj else None + args += (eval_obj, idx_cons) + + return self._execute(func, *args, func_callback) def _build_callback( self, i: Optional[int], expr: NonlinearExpressionData, eval_type: int ): is_obj = i is None - func = self._create_evaluator(expr, eval_type) + func = self._get_evaluator(expr, eval_type) - if is_obj and eval_type == knitro.KN_RC_EVALH: + if is_obj and eval_type == knitro.KN_RC_EVALFC: - def _callback(kc, cb, req, res, _): - res.hess[:] = func(req.x, req.sigma) + def _callback(req, res): + res.obj = func(req.x) return 0 elif is_obj and eval_type == knitro.KN_RC_EVALGA: - def _callback(kc, cb, req, res, _): + def _callback(req, res): res.objGrad[:] = func(req.x) return 0 - elif is_obj: + elif is_obj and eval_type == knitro.KN_RC_EVALH: - def _callback(kc, cb, req, res, _): - res.obj = func(req.x) + def _callback(req, res): + res.hess[:] = func(req.x, req.sigma) return 0 - elif eval_type == knitro.KN_RC_EVALH: + elif eval_type == knitro.KN_RC_EVALFC: - def _callback(kc, cb, req, res, _): - res.hess[:] = func(req.x, req.lambda_[i]) + def _callback(req, res): + res.c[:] = [func(req.x)] return 0 elif eval_type == knitro.KN_RC_EVALGA: - def _callback(kc, cb, req, res, _): + def _callback(req, res): res.jac[:] = func(req.x) return 0 - else: + elif eval_type == knitro.KN_RC_EVALH: - def _callback(kc, cb, req, res, _): - res.c[:] = [func(req.x)] + def _callback(req, res): + res.hess[:] = func(req.x, req.lambda_[i]) return 0 - return _callback + return lambda *args: _callback(args[2], args[3]) - def _add_callback( - self, - i: Optional[int], - expr: NonlinearExpressionData, - eval_type: int, - callback=None, - ): - func_callback = self._build_callback(i, expr, eval_type) + def _get_evaluator(self, expr: NonlinearExpressionData, eval_type: int): + vmap = self.mapping[VarData] if eval_type == knitro.KN_RC_EVALH: - hess_vars1, hess_vars2 = zip(*expr.hess_vars) - hess_idx_vars1 = self.get_var_idxs(hess_vars1) - hess_idx_vars2 = self.get_var_idxs(hess_vars2) - return self._execute( - knitro.KN_set_cb_hess, - callback, - hess_idx_vars1, - hess_idx_vars2, - func_callback, - ) + func = expr.create_hessian_evaluator elif eval_type == knitro.KN_RC_EVALGA: - idx_vars = self.get_var_idxs(expr.grad_vars) - is_obj = i is None - obj_grad_idx_vars = idx_vars if is_obj else None - jac_idx_cons = [i] * len(idx_vars) if not is_obj else None - jac_idx_vars = idx_vars if not is_obj else None - return self._execute( - knitro.KN_set_cb_grad, - callback, - obj_grad_idx_vars, - jac_idx_cons, - jac_idx_vars, - func_callback, - ) + func = expr.create_gradient_evaluator + elif eval_type == knitro.KN_RC_EVALFC: + func = expr.create_evaluator + return func(vmap) + + # ========= API FUNCTION GETTERS ================ + + def api_set_param(self, param_type: int): + if param_type == knitro.KN_PARAMTYPE_INTEGER: + return knitro.KN_set_int_param + elif param_type == knitro.KN_PARAMTYPE_FLOAT: + return knitro.KN_set_double_param + elif param_type == knitro.KN_PARAMTYPE_STRING: + return knitro.KN_set_char_param + + def api_add_items(self, item_type: Type[Union[VarData, ConstraintData]]): + if item_type is VarData: + return knitro.KN_add_vars + elif item_type is ConstraintData: + return knitro.KN_add_cons else: - eval_obj = i is None - idx_cons = [i] if not eval_obj else None - return self._execute( - knitro.KN_add_eval_callback, eval_obj, idx_cons, func_callback - ) + msg = "Invalid item type." + raise ValueError(msg) - def _register_callback(self, i: Optional[int], expr: NonlinearExpressionData): - callback = self._add_callback(i, expr, knitro.KN_RC_EVALFC) - if expr.grad is not None: - self._add_callback(i, expr, knitro.KN_RC_EVALGA, callback) - if expr.hessian is not None: - self._add_callback(i, expr, knitro.KN_RC_EVALH, callback) + def api_get_values( + self, item_type: Type[Union[VarData, ConstraintData]], is_dual: bool + ): + if item_type is VarData and not is_dual: + return knitro.KN_get_var_primal_values + elif item_type is VarData and is_dual: + return knitro.KN_get_var_dual_values + elif item_type is ConstraintData and is_dual: + return knitro.KN_get_con_dual_values + elif item_type is ConstraintData and not is_dual: + return knitro.KN_get_con_values + else: + msg = "Invalid item type or dual flag." + raise ValueError(msg) - def _register_callbacks(self): - for i, expr in self.nonlinear_map.items(): - self._register_callback(i, expr) + def api_set_bnds( + self, item_type: Type[Union[VarData, ConstraintData]], bnd_type: _BndType + ): + if item_type is VarData and bnd_type == self._BndType.EQ: + return knitro.KN_set_var_fxbnds + elif item_type is VarData and bnd_type == self._BndType.LO: + return knitro.KN_set_var_lobnds + elif item_type is VarData and bnd_type == self._BndType.UP: + return knitro.KN_set_var_upbnds + elif item_type is ConstraintData and bnd_type == self._BndType.EQ: + return knitro.KN_set_con_eqbnds + elif item_type is ConstraintData and bnd_type == self._BndType.LO: + return knitro.KN_set_con_lobnds + elif item_type is ConstraintData and bnd_type == self._BndType.UP: + return knitro.KN_set_con_upbnds + + def api_add_constant(self, is_obj: bool): + if is_obj: + return knitro.KN_add_obj_constant + else: + return knitro.KN_add_con_constants + + def api_add_linear_struct(self, is_obj: bool): + if is_obj: + return knitro.KN_add_obj_linear_struct + else: + return knitro.KN_add_con_linear_struct + + def api_add_quadratic_struct(self, is_obj: bool): + if is_obj: + return knitro.KN_add_obj_quadratic_struct + else: + return knitro.KN_add_con_quadratic_struct + + def api_add_callback(self, eval_type: int): + if eval_type == knitro.KN_RC_EVALH: + return knitro.KN_set_cb_hess + elif eval_type == knitro.KN_RC_EVALGA: + return knitro.KN_set_cb_grad + elif eval_type == knitro.KN_RC_EVALFC: + return knitro.KN_add_eval_callback diff --git a/pyomo/contrib/solver/solvers/knitro/solution.py b/pyomo/contrib/solver/solvers/knitro/solution.py index 48471a97609..1b0e391cd81 100644 --- a/pyomo/contrib/solver/solvers/knitro/solution.py +++ b/pyomo/contrib/solver/solvers/knitro/solution.py @@ -9,8 +9,8 @@ # This software is distributed under the 3-clause BSD License. # ___________________________________________________________________________ -from collections.abc import Callable, Mapping, Sequence -from typing import Optional, Protocol, Type, TypeVar, Union +from collections.abc import Mapping, Sequence +from typing import Optional, Protocol, Type, Union from pyomo.common.errors import PyomoException from pyomo.contrib.solver.common.solution_loader import SolutionLoaderBase @@ -24,30 +24,25 @@ 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_reduced_costs( - 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]: ... - - -T = TypeVar("T", bound=Union[VarData, ConstraintData]) + def get_error_type( + self, load_type: Type[Union[VarData, ConstraintData]], *, is_dual: bool + ) -> Type[PyomoException]: + if load_type is VarData and not is_dual: + return NoSolutionError + elif load_type is VarData and is_dual: + return NoReducedCostsError + elif load_type is ConstraintData and is_dual: + return NoDualsError -def get_values( - to_load: Optional[Sequence[T]], - loader: Callable[[Optional[Sequence[T]]], Mapping[T, float]], - is_success: bool, - error_type: Type[PyomoException], -) -> Mapping[T, float]: - if not is_success: - raise error_type() - return loader(to_load) + def get_num_solutions(self) -> int: ... + def get_values( + self, + load_type: Type[Union[VarData, ConstraintData]], + to_load: Optional[Union[Sequence[VarData], Sequence[ConstraintData]]], + *, + is_dual: bool, + ) -> Mapping[Union[VarData, ConstraintData], float]: ... class SolutionLoader(SolutionLoaderBase): @@ -73,12 +68,24 @@ def __init__( def get_number_of_solutions(self) -> int: return self._provider.get_num_solutions() + def get_values( + self, + load_type: Type[Union[VarData, ConstraintData]], + to_load: Optional[Union[Sequence[VarData], Sequence[ConstraintData]]], + *, + is_dual: bool, + check: bool, + ): + if not check: + error_type = self._provider.get_error_type(load_type, is_dual=is_dual) + raise error_type() + + return self._provider.get_values(load_type, to_load, is_dual=is_dual) + def get_vars( self, vars_to_load: Optional[Sequence[VarData]] = None ) -> Mapping[VarData, float]: - return get_values( - vars_to_load, self._provider.get_primals, self.has_primals, NoSolutionError - ) + return self.get_values(VarData, vars_to_load, is_dual=False, check=self.has_primals) # TODO: remove this when the solution loader is fixed. def get_primals(self, vars_to_load=None): @@ -87,16 +94,9 @@ def get_primals(self, vars_to_load=None): def get_reduced_costs( self, vars_to_load: Optional[Sequence[VarData]] = None ) -> Mapping[VarData, float]: - return get_values( - vars_to_load, - self._provider.get_reduced_costs, - self.has_reduced_costs, - NoReducedCostsError, - ) + return self.get_values(VarData, vars_to_load, is_dual=True, check=self.has_reduced_costs) def get_duals( self, cons_to_load: Optional[Sequence[ConstraintData]] = None ) -> Mapping[ConstraintData, float]: - return get_values( - cons_to_load, self._provider.get_duals, self.has_duals, NoDualsError - ) + return self.get_values(ConstraintData, cons_to_load, is_dual=True, check=self.has_duals) diff --git a/pyomo/contrib/solver/solvers/knitro/utils.py b/pyomo/contrib/solver/solvers/knitro/utils.py index 5ef3dd33a8a..5d282b18dca 100644 --- a/pyomo/contrib/solver/solvers/knitro/utils.py +++ b/pyomo/contrib/solver/solvers/knitro/utils.py @@ -11,7 +11,7 @@ from collections.abc import Iterable, Mapping, MutableSet -from typing import Any, List, Optional, Tuple +from typing import Any, Callable, List, Optional, Tuple from pyomo.common.collections import ComponentMap, ComponentSet from pyomo.common.numeric_types import value @@ -74,6 +74,13 @@ class Problem: _vars: MutableSet[VarData] def __init__(self, block: Optional[BlockData] = None): + """ + Initialize a Problem instance. + + Args: + block (Optional[BlockData]): Pyomo block to initialize from. If None, + creates an empty problem that can be populated later. + """ self._vars = ComponentSet() self.objs = [] self.cons = [] @@ -82,46 +89,67 @@ def __init__(self, block: Optional[BlockData] = None): self.add_block(block) def clear(self): + """Clear all objectives, constraints, and variables from the problem.""" self.objs.clear() self.cons.clear() self.variables.clear() self._vars.clear() def set_block(self, block: BlockData): + """ + Replace the current problem data with data from a new block. + + Args: + block (BlockData): The Pyomo block to extract data from. + """ self.clear() self.add_block(block) def add_block(self, block: BlockData): + """ + Add objectives, constraints, and variables from a block to the problem. + + Args: + block (BlockData): The Pyomo block to extract data from. + """ new_objs = get_active_objectives(block) new_cons = get_active_constraints(block) self.objs.extend(new_objs) self.cons.extend(new_cons) + # Collect variables from objectives for obj in new_objs: _, variables, _, _ = collect_vars_and_named_exprs(obj.expr) - for v in variables: - self._vars.add(v) + self._vars.update(variables) + + # Collect variables from constraints for con in new_cons: _, variables, _, _ = collect_vars_and_named_exprs(con.body) - for v in variables: - self._vars.add(v) - self.variables.extend(self._vars) + self._vars.update(variables) + + # Update the variables list with unique variables only + self.variables = list(self._vars) class NonlinearExpressionData: """ Holds the data required to evaluate a non-linear expression. + This class stores a Pyomo expression along with its variables and can compute + gradient and Hessian information for use with optimization solvers. + 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. + grad (Optional[Mapping[VarData, Any]]): Gradient expressions mapped by variable. + hess (Optional[Mapping[Tuple[VarData, VarData], Any]]): Hessian expressions + mapped by variable pairs. """ body: Optional[Any] variables: List[VarData] grad: Optional[Mapping[VarData, Any]] - hessian: Optional[Mapping[Tuple[VarData, VarData], Any]] + hess: Optional[Mapping[Tuple[VarData, VarData], Any]] def __init__( self, @@ -130,10 +158,21 @@ def __init__( *, differentiation_order: int = 0, ): + """ + Initialize NonlinearExpressionData. + + Args: + expr (Optional[Any]): The Pyomo expression to evaluate. + variables (Iterable[VarData]): Variables referenced in the expression. + differentiation_order (int): Level of differentiation to compute: + - 0: function evaluation only + - 1: function + gradient + - 2: function + gradient + Hessian + """ self.body = expr self.variables = list(variables) self.grad = None - self.hessian = None + self.hess = None if differentiation_order > 0: self.compute_gradient() if differentiation_order > 1: @@ -141,6 +180,15 @@ def __init__( @property def grad_vars(self) -> List[VarData]: + """ + Get the list of variables for which gradients are available. + + Returns: + List[VarData]: Variables with gradient information. + + Raises: + ValueError: If gradient information is not available. + """ if self.grad is None: msg = "Gradient information is not available for this expression." raise ValueError(msg) @@ -148,46 +196,74 @@ def grad_vars(self) -> List[VarData]: @property def hess_vars(self) -> List[Tuple[VarData, VarData]]: - if self.hessian is None: + """ + Get the list of variable pairs for which Hessian entries are available. + + Returns: + List[Tuple[VarData, VarData]]: Variable pairs with Hessian information. + + Raises: + ValueError: If Hessian information is not available. + """ + if self.hess is None: msg = "Hessian information is not available for this expression." raise ValueError(msg) - return list(self.hessian.keys()) + return list(self.hess.keys()) def compute_gradient(self): - diff_map = reverse_sd(self.body) + """ + Compute gradient expressions for the nonlinear expression. + + This method computes the gradient of the expression with respect to all + variables and stores the results in the grad attribute. + """ + derivative = reverse_sd(self.body) variables = ComponentSet(self.variables) self.grad = ComponentMap() - for v, expr in diff_map.items(): + for v, expr in derivative.items(): if v in variables: self.grad[v] = expr def compute_hessian(self): + """ + Compute Hessian expressions for the nonlinear expression. + + This method computes the Hessian matrix of the expression with respect to all + variables and stores the results in the hess attribute. Only the upper triangle + of the Hessian is stored to avoid redundancy. + + Note: + This method requires that compute_gradient() has been called first. + """ + if self.grad is None: + msg = "Gradient must be computed before Hessian. Call compute_gradient() first." + raise ValueError(msg) + variables = ComponentSet(self.variables) - self.hessian = ComponentMap() - for v1, expr in self.grad.items(): - diff_map = reverse_sd(expr) - for v2, diff_expr in diff_map.items(): + self.hess = ComponentMap() + for v1, grad_expr in self.grad.items(): + derivative = reverse_sd(grad_expr) + for v2, hess_expr in derivative.items(): if v2 not in variables: continue - var1 = v1 - var2 = v2 - if id(var1) > id(var2): - var1, var2 = var2, var1 - if (var1, var2) not in self.hessian: - self.hessian[(var1, var2)] = diff_expr - else: - self.hessian[(var1, var2)] += diff_expr - - def create_evaluator(self, vmap: Mapping[int, int]): + # Store only upper triangle: ensure var1 <= var2 by ID + var1, var2 = (v1, v2) if id(v1) <= id(v2) else (v2, v1) + if (var1, var2) not in self.hess: + self.hess[(var1, var2)] = hess_expr + + def create_evaluator( + self, vmap: Mapping[int, int] + ) -> Callable[[List[float]], float]: """ 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. + 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. + 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: @@ -198,26 +274,28 @@ def _fn(x: List[float]) -> float: return _fn - def create_gradient_evaluator(self, vmap: Mapping[int, int]): + def create_gradient_evaluator( + self, vmap: Mapping[int, int] + ) -> Callable[[List[float]], List[float]]: """ 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. + 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. + 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 all variables, not just gradient variables, to ensure consistency for var in self.variables: i = vmap[id(var)] var.set_value(x[i]) @@ -225,27 +303,32 @@ def _grad(x: List[float]) -> List[float]: return _grad - def create_hessian_evaluator(self, vmap: Mapping[int, int]): + def create_hessian_evaluator( + self, vmap: Mapping[int, int] + ) -> Callable[[List[float], float], List[float]]: """ Create a callable Hessian evaluator for the non-linear expression. Args: - vmap (Mapping[int, int]): A mapping from variable id to index in the solver's variable vector. + vmap (Mapping[int, int]): A mapping from variable id to index in the + solver's variable vector. + Returns: - Callable[[List[float]], List[Tuple[int, int, float]]]: A function that takes a list of variable values (x) - and returns the Hessian of the expression as a list of (row, column, value) tuples. - Raises: - ValueError: If Hessian information is not available for this expression. - """ + Callable[[List[float], float], List[float]]: A function that takes a list + of variable values (x) and a multiplier (mu) and returns the scaled + Hessian of the expression as a list of values corresponding to the + variable pairs in self.hess. - if self.hessian is None: + """ + if self.hess is None: msg = "Hessian information is not available for this expression." raise ValueError(msg) def _hess(x: List[float], mu: float) -> List[float]: + # Set all variables to ensure consistency for var in self.variables: i = vmap[id(var)] var.set_value(x[i]) - return [mu * value(expr) for expr in self.hessian.values()] + return [mu * value(expr) for expr in self.hess.values()] return _hess From 8aabcdf3a8b4cee69ad5793d477af7e0a524ee87 Mon Sep 17 00:00:00 2001 From: eminyouskn Date: Sun, 21 Sep 2025 21:43:14 -0400 Subject: [PATCH 38/64] Black format --- pyomo/contrib/solver/solvers/knitro/solution.py | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/pyomo/contrib/solver/solvers/knitro/solution.py b/pyomo/contrib/solver/solvers/knitro/solution.py index 1b0e391cd81..cd3afc52c5e 100644 --- a/pyomo/contrib/solver/solvers/knitro/solution.py +++ b/pyomo/contrib/solver/solvers/knitro/solution.py @@ -85,7 +85,9 @@ def get_values( def get_vars( self, vars_to_load: Optional[Sequence[VarData]] = None ) -> Mapping[VarData, float]: - return self.get_values(VarData, vars_to_load, is_dual=False, check=self.has_primals) + return self.get_values( + VarData, vars_to_load, is_dual=False, check=self.has_primals + ) # TODO: remove this when the solution loader is fixed. def get_primals(self, vars_to_load=None): @@ -94,9 +96,13 @@ def get_primals(self, vars_to_load=None): def get_reduced_costs( self, vars_to_load: Optional[Sequence[VarData]] = None ) -> Mapping[VarData, float]: - return self.get_values(VarData, vars_to_load, is_dual=True, check=self.has_reduced_costs) + return self.get_values( + VarData, vars_to_load, is_dual=True, check=self.has_reduced_costs + ) def get_duals( self, cons_to_load: Optional[Sequence[ConstraintData]] = None ) -> Mapping[ConstraintData, float]: - return self.get_values(ConstraintData, cons_to_load, is_dual=True, check=self.has_duals) + return self.get_values( + ConstraintData, cons_to_load, is_dual=True, check=self.has_duals + ) From 36a1d2ea1271d956c309d2d8b52587450c5e4767 Mon Sep 17 00:00:00 2001 From: eminyouskn Date: Sun, 21 Sep 2025 22:24:48 -0400 Subject: [PATCH 39/64] Refactor type annotations. --- pyomo/contrib/solver/solvers/knitro/base.py | 10 +-- pyomo/contrib/solver/solvers/knitro/engine.py | 80 ++++++++----------- .../contrib/solver/solvers/knitro/solution.py | 29 +++---- 3 files changed, 55 insertions(+), 64 deletions(-) diff --git a/pyomo/contrib/solver/solvers/knitro/base.py b/pyomo/contrib/solver/solvers/knitro/base.py index 9f7e8a6fc36..2801d542ca5 100644 --- a/pyomo/contrib/solver/solvers/knitro/base.py +++ b/pyomo/contrib/solver/solvers/knitro/base.py @@ -14,7 +14,7 @@ from collections.abc import Mapping, Sequence from datetime import datetime, timezone from io import StringIO -from typing import Optional, Type, Union +from typing import Optional, Type from pyomo.common.collections import ComponentMap from pyomo.common.errors import ApplicationError @@ -41,7 +41,7 @@ from .engine import Engine from .package import PackageChecker from .utils import Problem -from .solution import SolutionLoader, SolutionProvider +from .solution import LoadType, SolutionLoader, SolutionProvider class SolverBase(SolutionProvider, PackageChecker, base.SolverBase): @@ -175,7 +175,7 @@ def get_objectives(self): def get_cons(self): return self._problem.cons - def get_fetch_all(self, load_type: Type[Union[VarData, ConstraintData]]): + def get_fetch_all(self, load_type: Type[LoadType]): if load_type is VarData: return self.get_vars elif load_type is ConstraintData: @@ -183,8 +183,8 @@ def get_fetch_all(self, load_type: Type[Union[VarData, ConstraintData]]): def get_values( self, - load_type: Type[Union[VarData, ConstraintData]], - to_load: Optional[Union[Sequence[VarData], Sequence[ConstraintData]]], + load_type: Type[LoadType], + to_load: Optional[Sequence[LoadType]], *, is_dual: bool, ): diff --git a/pyomo/contrib/solver/solvers/knitro/engine.py b/pyomo/contrib/solver/solvers/knitro/engine.py index 8e72505d91a..afed8bfd235 100644 --- a/pyomo/contrib/solver/solvers/knitro/engine.py +++ b/pyomo/contrib/solver/solvers/knitro/engine.py @@ -11,6 +11,7 @@ from .api import knitro from .package import Package from .utils import NonlinearExpressionData +from .solution import LoadType class Engine: @@ -29,9 +30,11 @@ class Engine: _status: Optional[int] def __init__(self, *, differentiation_order: int = 2): - # True: variables, False: constraints + # VarData: id(var) -> idx_var + # ConstraintData: id(con) -> idx_con self.mapping = {VarData: {}, ConstraintData: {}} - # None: objective + # None -> objective + # idx_con -> constraint self.nonlinear_map = {} self.differentiation_order = differentiation_order @@ -134,8 +137,8 @@ def get_obj_value(self) -> Optional[float]: def get_values( self, - item_type: Type[Union[VarData, ConstraintData]], - items: Union[Iterable[VarData], Iterable[ConstraintData]], + item_type: Type[LoadType], + items: Iterable[LoadType], *, is_dual: bool, ): @@ -155,21 +158,22 @@ def _execute(self, api_func, *args, **kwargs): def _get_idxs( self, - item_type: Type[Union[VarData, ConstraintData]], - items: Union[Iterable[VarData], Iterable[ConstraintData]], + item_type: Type[LoadType], + items: Iterable[LoadType], ): imap = self.mapping[item_type] return [imap[id(item)] for item in items] def _add_items( self, - item_type: Type[Union[VarData, ConstraintData]], - items: Union[Iterable[VarData], Iterable[ConstraintData]], + item_type: Type[LoadType], + items: Iterable[LoadType], ): func = self.api_add_items(item_type) - idxs = self._execute(func, len(items)) + items_seq = list(items) + idxs = self._execute(func, len(items_seq)) if idxs is not None: - self.mapping[item_type].update(zip(map(id, items), idxs)) + self.mapping[item_type].update(zip(map(id, items_seq), idxs)) # ====== PRIVATE VARIABLE TYPE HANDLING ========= @@ -191,7 +195,7 @@ def _parse_var_type( elif var.is_continuous(): var_types[i] = knitro.KN_VARTYPE_CONTINUOUS else: - msg = f"Unknown variable type for variable {var.name}." + msg = f"Unsupported variable type for variable {var.name}." raise ValueError(msg) # ========== PRIVATE BOUNDS HANDLING ============ @@ -201,7 +205,7 @@ class _BndType(Enum): LO = 1 UP = 2 - def _get_parse_bnds(self, item_type: Type[Union[VarData, ConstraintData]]): + def _get_parse_bnds(self, item_type: Type[LoadType]): if item_type is VarData: return self._parse_var_bnds elif item_type is ConstraintData: @@ -209,8 +213,8 @@ def _get_parse_bnds(self, item_type: Type[Union[VarData, ConstraintData]]): def _set_bnds( self, - item_type: Type[Union[VarData, ConstraintData]], - items: Union[Iterable[VarData], Iterable[ConstraintData]], + item_type: Type[LoadType], + items: Iterable[LoadType], ): parse = self._get_parse_bnds(item_type) bnds_map = {bnd_type: {} for bnd_type in self._BndType} @@ -306,25 +310,17 @@ def _register_callbacks(self): self._register_callback(i, expr) def _register_callback(self, i: Optional[int], expr: NonlinearExpressionData): - callback = self._add_callback(i, expr, knitro.KN_RC_EVALFC) + callback = self._add_callback(knitro.KN_RC_EVALFC, i, expr) if expr.grad is not None: - self._add_callback(i, expr, knitro.KN_RC_EVALGA, callback) + self._add_callback(knitro.KN_RC_EVALGA, i, expr, callback) if expr.hess is not None: - self._add_callback(i, expr, knitro.KN_RC_EVALH, callback) + self._add_callback(knitro.KN_RC_EVALH, i, expr, callback) def _add_callback( - self, - i: Optional[int], - expr: NonlinearExpressionData, - eval_type: int, - callback=None, + self, eval_type: int, i: Optional[int], expr: NonlinearExpressionData, *args ): func = self.api_add_callback(eval_type) - func_callback = self._build_callback(i, expr, eval_type) - - args = () - if callback is not None: - args += (callback,) + func_callback = self._build_callback(eval_type, i, expr) if eval_type == knitro.KN_RC_EVALH: hess_vars1, hess_vars2 = zip(*expr.hess_vars) @@ -346,10 +342,10 @@ def _add_callback( return self._execute(func, *args, func_callback) def _build_callback( - self, i: Optional[int], expr: NonlinearExpressionData, eval_type: int + self, eval_type: int, i: Optional[int], expr: NonlinearExpressionData ): is_obj = i is None - func = self._get_evaluator(expr, eval_type) + func = self._get_evaluator(eval_type, expr) if is_obj and eval_type == knitro.KN_RC_EVALFC: @@ -389,7 +385,7 @@ def _callback(req, res): return lambda *args: _callback(args[2], args[3]) - def _get_evaluator(self, expr: NonlinearExpressionData, eval_type: int): + def _get_evaluator(self, eval_type: int, expr: NonlinearExpressionData): vmap = self.mapping[VarData] if eval_type == knitro.KN_RC_EVALH: func = expr.create_hessian_evaluator @@ -409,17 +405,14 @@ def api_set_param(self, param_type: int): elif param_type == knitro.KN_PARAMTYPE_STRING: return knitro.KN_set_char_param - def api_add_items(self, item_type: Type[Union[VarData, ConstraintData]]): + def api_add_items(self, item_type: Type[LoadType]): if item_type is VarData: return knitro.KN_add_vars elif item_type is ConstraintData: return knitro.KN_add_cons - else: - msg = "Invalid item type." - raise ValueError(msg) def api_get_values( - self, item_type: Type[Union[VarData, ConstraintData]], is_dual: bool + self, item_type: Type[LoadType], is_dual: bool ): if item_type is VarData and not is_dual: return knitro.KN_get_var_primal_values @@ -429,24 +422,21 @@ def api_get_values( return knitro.KN_get_con_dual_values elif item_type is ConstraintData and not is_dual: return knitro.KN_get_con_values - else: - msg = "Invalid item type or dual flag." - raise ValueError(msg) def api_set_bnds( - self, item_type: Type[Union[VarData, ConstraintData]], bnd_type: _BndType + self, item_type: Type[LoadType], bnd_type: _BndType ): - if item_type is VarData and bnd_type == self._BndType.EQ: + if item_type is VarData and bnd_type == Engine._BndType.EQ: return knitro.KN_set_var_fxbnds - elif item_type is VarData and bnd_type == self._BndType.LO: + elif item_type is VarData and bnd_type == Engine._BndType.LO: return knitro.KN_set_var_lobnds - elif item_type is VarData and bnd_type == self._BndType.UP: + elif item_type is VarData and bnd_type == Engine._BndType.UP: return knitro.KN_set_var_upbnds - elif item_type is ConstraintData and bnd_type == self._BndType.EQ: + elif item_type is ConstraintData and bnd_type == Engine._BndType.EQ: return knitro.KN_set_con_eqbnds - elif item_type is ConstraintData and bnd_type == self._BndType.LO: + elif item_type is ConstraintData and bnd_type == Engine._BndType.LO: return knitro.KN_set_con_lobnds - elif item_type is ConstraintData and bnd_type == self._BndType.UP: + elif item_type is ConstraintData and bnd_type == Engine._BndType.UP: return knitro.KN_set_con_upbnds def api_add_constant(self, is_obj: bool): diff --git a/pyomo/contrib/solver/solvers/knitro/solution.py b/pyomo/contrib/solver/solvers/knitro/solution.py index cd3afc52c5e..5dd5d0d5890 100644 --- a/pyomo/contrib/solver/solvers/knitro/solution.py +++ b/pyomo/contrib/solver/solvers/knitro/solution.py @@ -10,7 +10,7 @@ # ___________________________________________________________________________ from collections.abc import Mapping, Sequence -from typing import Optional, Protocol, Type, Union +from typing import Optional, Protocol, Type, TypeVar, Union from pyomo.common.errors import PyomoException from pyomo.contrib.solver.common.solution_loader import SolutionLoaderBase @@ -22,11 +22,12 @@ from pyomo.core.base.constraint import ConstraintData from pyomo.core.base.var import VarData +LoadType = TypeVar("LoadType", bound=Union[VarData, ConstraintData]) -class SolutionProvider(Protocol): +class SolutionProvider(Protocol): def get_error_type( - self, load_type: Type[Union[VarData, ConstraintData]], *, is_dual: bool + self, load_type: Type[LoadType], *, is_dual: bool ) -> Type[PyomoException]: if load_type is VarData and not is_dual: return NoSolutionError @@ -38,11 +39,11 @@ def get_error_type( def get_num_solutions(self) -> int: ... def get_values( self, - load_type: Type[Union[VarData, ConstraintData]], - to_load: Optional[Union[Sequence[VarData], Sequence[ConstraintData]]], + load_type: Type[LoadType], + to_load: Optional[Sequence[LoadType]], *, is_dual: bool, - ) -> Mapping[Union[VarData, ConstraintData], float]: ... + ) -> Mapping[LoadType, float]: ... class SolutionLoader(SolutionLoaderBase): @@ -70,13 +71,13 @@ def get_number_of_solutions(self) -> int: def get_values( self, - load_type: Type[Union[VarData, ConstraintData]], - to_load: Optional[Union[Sequence[VarData], Sequence[ConstraintData]]], + load_type: Type[LoadType], + to_load: Optional[Sequence[LoadType]], *, is_dual: bool, - check: bool, - ): - if not check: + exists: bool, + ) -> Mapping[LoadType, float]: + if not exists: error_type = self._provider.get_error_type(load_type, is_dual=is_dual) raise error_type() @@ -86,7 +87,7 @@ def get_vars( self, vars_to_load: Optional[Sequence[VarData]] = None ) -> Mapping[VarData, float]: return self.get_values( - VarData, vars_to_load, is_dual=False, check=self.has_primals + VarData, vars_to_load, is_dual=False, exists=self.has_primals ) # TODO: remove this when the solution loader is fixed. @@ -97,12 +98,12 @@ def get_reduced_costs( self, vars_to_load: Optional[Sequence[VarData]] = None ) -> Mapping[VarData, float]: return self.get_values( - VarData, vars_to_load, is_dual=True, check=self.has_reduced_costs + VarData, vars_to_load, is_dual=True, exists=self.has_reduced_costs ) def get_duals( self, cons_to_load: Optional[Sequence[ConstraintData]] = None ) -> Mapping[ConstraintData, float]: return self.get_values( - ConstraintData, cons_to_load, is_dual=True, check=self.has_duals + ConstraintData, cons_to_load, is_dual=True, exists=self.has_duals ) From ced727d78fb6a2ab503a4bdffa2dc3f5b730bc5f Mon Sep 17 00:00:00 2001 From: eminyouskn Date: Sun, 21 Sep 2025 22:25:58 -0400 Subject: [PATCH 40/64] Black format --- pyomo/contrib/solver/solvers/knitro/engine.py | 32 ++++--------------- 1 file changed, 6 insertions(+), 26 deletions(-) diff --git a/pyomo/contrib/solver/solvers/knitro/engine.py b/pyomo/contrib/solver/solvers/knitro/engine.py index afed8bfd235..09654dfa489 100644 --- a/pyomo/contrib/solver/solvers/knitro/engine.py +++ b/pyomo/contrib/solver/solvers/knitro/engine.py @@ -136,11 +136,7 @@ def get_obj_value(self) -> Optional[float]: return self._execute(knitro.KN_get_obj_value) def get_values( - self, - item_type: Type[LoadType], - items: Iterable[LoadType], - *, - is_dual: bool, + self, item_type: Type[LoadType], items: Iterable[LoadType], *, is_dual: bool ): func = self.api_get_values(item_type, is_dual) idxs = self._get_idxs(item_type, items) @@ -156,19 +152,11 @@ def _execute(self, api_func, *args, **kwargs): # ======== PRIVATE COMPONENT MANAGEMENT ========= - def _get_idxs( - self, - item_type: Type[LoadType], - items: Iterable[LoadType], - ): + def _get_idxs(self, item_type: Type[LoadType], items: Iterable[LoadType]): imap = self.mapping[item_type] return [imap[id(item)] for item in items] - def _add_items( - self, - item_type: Type[LoadType], - items: Iterable[LoadType], - ): + def _add_items(self, item_type: Type[LoadType], items: Iterable[LoadType]): func = self.api_add_items(item_type) items_seq = list(items) idxs = self._execute(func, len(items_seq)) @@ -211,11 +199,7 @@ def _get_parse_bnds(self, item_type: Type[LoadType]): elif item_type is ConstraintData: return self._parse_con_bnds - def _set_bnds( - self, - item_type: Type[LoadType], - items: Iterable[LoadType], - ): + def _set_bnds(self, item_type: Type[LoadType], items: Iterable[LoadType]): parse = self._get_parse_bnds(item_type) bnds_map = {bnd_type: {} for bnd_type in self._BndType} imap = self.mapping[item_type] @@ -411,9 +395,7 @@ def api_add_items(self, item_type: Type[LoadType]): elif item_type is ConstraintData: return knitro.KN_add_cons - def api_get_values( - self, item_type: Type[LoadType], is_dual: bool - ): + def api_get_values(self, item_type: Type[LoadType], is_dual: bool): if item_type is VarData and not is_dual: return knitro.KN_get_var_primal_values elif item_type is VarData and is_dual: @@ -423,9 +405,7 @@ def api_get_values( elif item_type is ConstraintData and not is_dual: return knitro.KN_get_con_values - def api_set_bnds( - self, item_type: Type[LoadType], bnd_type: _BndType - ): + def api_set_bnds(self, item_type: Type[LoadType], bnd_type: _BndType): if item_type is VarData and bnd_type == Engine._BndType.EQ: return knitro.KN_set_var_fxbnds elif item_type is VarData and bnd_type == Engine._BndType.LO: From 65d319f832091c0b90042a2e20aa5bf46529bcf7 Mon Sep 17 00:00:00 2001 From: eminyouskn Date: Tue, 23 Sep 2025 00:21:34 -0400 Subject: [PATCH 41/64] Clean and improve typehint --- .../contrib/solver/solvers/knitro/__init__.py | 2 +- pyomo/contrib/solver/solvers/knitro/api.py | 6 +- pyomo/contrib/solver/solvers/knitro/base.py | 73 +- pyomo/contrib/solver/solvers/knitro/config.py | 4 +- pyomo/contrib/solver/solvers/knitro/direct.py | 4 +- pyomo/contrib/solver/solvers/knitro/engine.py | 684 +++++++++--------- .../contrib/solver/solvers/knitro/package.py | 21 +- .../contrib/solver/solvers/knitro/solution.py | 52 +- pyomo/contrib/solver/solvers/knitro/typing.py | 74 ++ pyomo/contrib/solver/solvers/knitro/utils.py | 239 +++--- 10 files changed, 586 insertions(+), 573 deletions(-) create mode 100644 pyomo/contrib/solver/solvers/knitro/typing.py diff --git a/pyomo/contrib/solver/solvers/knitro/__init__.py b/pyomo/contrib/solver/solvers/knitro/__init__.py index e16a79fcf5a..9fab0c8f742 100644 --- a/pyomo/contrib/solver/solvers/knitro/__init__.py +++ b/pyomo/contrib/solver/solvers/knitro/__init__.py @@ -18,7 +18,7 @@ # This function needs to be called from the plugins load function -def load(): +def load() -> None: SolverFactory.register( name="knitro_direct", legacy_name="knitro_direct", diff --git a/pyomo/contrib/solver/solvers/knitro/api.py b/pyomo/contrib/solver/solvers/knitro/api.py index a3f7d7eae9d..0d14643bbb6 100644 --- a/pyomo/contrib/solver/solvers/knitro/api.py +++ b/pyomo/contrib/solver/solvers/knitro/api.py @@ -10,9 +10,9 @@ # ___________________________________________________________________________ -from pyomo.common.dependencies import attempt_import - # import knitro +from pyomo.common.dependencies import attempt_import + knitro, KNITRO_AVAILABLE = attempt_import("knitro") -KNITRO_VERSION = knitro.__version__ if KNITRO_AVAILABLE else "0.0.0" +KNITRO_VERSION = knitro.__version__ if bool(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 2801d542ca5..44b82219142 100644 --- a/pyomo/contrib/solver/solvers/knitro/base.py +++ b/pyomo/contrib/solver/solvers/knitro/base.py @@ -9,12 +9,11 @@ # This software is distributed under the 3-clause BSD License. # ___________________________________________________________________________ - from abc import abstractmethod -from collections.abc import Mapping, Sequence +from collections.abc import Sequence from datetime import datetime, timezone from io import StringIO -from typing import Optional, Type +from typing import Optional, Union from pyomo.common.collections import ComponentMap from pyomo.common.errors import ApplicationError @@ -40,8 +39,9 @@ from .config import Config from .engine import Engine from .package import PackageChecker +from .solution import SolutionLoader, SolutionProvider +from .typing import UnreachableError, ValueType from .utils import Problem -from .solution import LoadType, SolutionLoader, SolutionProvider class SolverBase(SolutionProvider, PackageChecker, base.SolverBase): @@ -51,7 +51,7 @@ class SolverBase(SolutionProvider, PackageChecker, base.SolverBase): _engine: Engine _problem: Problem _stream: StringIO - _saved_var_values: Mapping[int, float] + _saved_var_values: dict[int, Optional[float]] def __init__(self, **kwds) -> None: PackageChecker.__init__(self) @@ -59,6 +59,7 @@ def __init__(self, **kwds) -> None: self._engine = Engine() self._problem = Problem() self._stream = StringIO() + self._saved_var_values = {} def solve(self, model: BlockData, **kwds) -> Results: tick = datetime.now(timezone.utc) @@ -105,7 +106,9 @@ def _check_available(self) -> None: raise ApplicationError(msg) def _save_var_values(self) -> None: - self._saved_var_values = {id(var): value(var.value) for var in self.get_vars()} + self._saved_var_values.clear() + for var in self.get_vars(): + self._saved_var_values[id(var)] = value(var.value) def _restore_var_values(self) -> None: StaleFlagManager.mark_all_as_stale(delayed=True) @@ -130,16 +133,9 @@ def _postsolve(self, config: Config, timer: HierarchicalTimer) -> Results: results.solver_version = self.version() results.solver_log = self._stream.getvalue() results.solver_config = config - results.solution_status = self.get_solution_status(status) - results.termination_condition = self.get_termination_condition(status) - if self._problem.objs and results.termination_condition in { - TerminationCondition.convergenceCriteriaSatisfied, - TerminationCondition.iterationLimit, - TerminationCondition.maxTimeLimit, - }: - results.incumbent_objective = self._engine.get_obj_value() - else: - results.incumbent_objective = None + 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.solve_time = self._engine.get_solve_time() results.timing_info.timer = timer @@ -166,43 +162,36 @@ def _postsolve(self, config: Config, timer: HierarchicalTimer) -> Results: return results - def get_vars(self): + def get_vars(self) -> list[VarData]: return self._problem.variables - def get_objectives(self): - return self._problem.objs - - def get_cons(self): - return self._problem.cons - - def get_fetch_all(self, load_type: Type[LoadType]): - if load_type is VarData: - return self.get_vars - elif load_type is ConstraintData: - return self.get_cons + def get_items(self, item_type: type): + if item_type is VarData: + return self._problem.variables + elif item_type is ConstraintData: + return self._problem.cons + raise UnreachableError() def get_values( self, - load_type: Type[LoadType], - to_load: Optional[Sequence[LoadType]], - *, - is_dual: bool, + item_type: type, + value_type: ValueType, + items: Optional[Union[Sequence[VarData], Sequence[ConstraintData]]] = None, ): - fetch_all = self.get_fetch_all(load_type) - if to_load is None: - to_load = fetch_all() - x = self._engine.get_values(load_type, to_load, is_dual=is_dual) + if items is None: + items = self.get_items(item_type) + x = self._engine.get_values(item_type, value_type, items) if x is None: - error_type = self.get_error_type(load_type, is_dual=is_dual) + error_type = SolutionProvider.get_error_type(item_type, value_type) raise error_type() - sign = -1.0 if is_dual else 1.0 - return ComponentMap([(k, sign * x[i]) for i, k in enumerate(to_load)]) + sign = value_type.sign + return ComponentMap([(k, sign * xk) for k, xk in zip(items, x)]) - def get_num_solutions(self): + def get_num_solutions(self) -> int: return self._engine.get_num_solutions() @staticmethod - def get_solution_status(status: int) -> SolutionStatus: + def _get_solution_status(status: int) -> SolutionStatus: if ( status == knitro.KN_RC_OPTIMAL or status == knitro.KN_RC_OPTIMAL_OR_SATISFACTORY @@ -222,7 +211,7 @@ def get_solution_status(status: int) -> SolutionStatus: return SolutionStatus.noSolution @staticmethod - def get_termination_condition(status: int) -> TerminationCondition: + def _get_termination_condition(status: int) -> TerminationCondition: if ( status == knitro.KN_RC_OPTIMAL or status == knitro.KN_RC_OPTIMAL_OR_SATISFACTORY diff --git a/pyomo/contrib/solver/solvers/knitro/config.py b/pyomo/contrib/solver/solvers/knitro/config.py index 217ad82117d..d12f21d5417 100644 --- a/pyomo/contrib/solver/solvers/knitro/config.py +++ b/pyomo/contrib/solver/solvers/knitro/config.py @@ -21,7 +21,7 @@ def __init__( implicit=False, implicit_domain=None, visibility=0, - ): + ) -> None: super().__init__( description=description, doc=doc, @@ -43,7 +43,7 @@ def __init__( "restore_variable_values_after_solve", ConfigValue( domain=Bool, - default=True, + default=False, 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/direct.py b/pyomo/contrib/solver/solvers/knitro/direct.py index b24ccae389d..fdf55c8b509 100644 --- a/pyomo/contrib/solver/solvers/knitro/direct.py +++ b/pyomo/contrib/solver/solvers/knitro/direct.py @@ -18,7 +18,9 @@ class Solver(SolverBase): - def _presolve(self, model: BlockData, config: Config, timer: HierarchicalTimer): + def _presolve( + self, model: BlockData, config: Config, timer: HierarchicalTimer + ) -> None: timer.start("build_problem") self._problem.set_block(model) timer.stop("build_problem") diff --git a/pyomo/contrib/solver/solvers/knitro/engine.py b/pyomo/contrib/solver/solvers/knitro/engine.py index 09654dfa489..1aec0fb86ed 100644 --- a/pyomo/contrib/solver/solvers/knitro/engine.py +++ b/pyomo/contrib/solver/solvers/knitro/engine.py @@ -1,7 +1,7 @@ -from collections.abc import Iterable, Mapping, MutableMapping -from typing import List, Optional, Type, Union +from collections.abc import Callable, Iterable, Mapping, MutableMapping, Sequence +from typing import Any, Optional, Protocol, TypeVar, Union -from pyomo.common.enums import Enum, ObjectiveSense +from pyomo.common.enums import ObjectiveSense from pyomo.common.numeric_types import value from pyomo.core.base.constraint import ConstraintData from pyomo.core.base.objective import ObjectiveData @@ -10,437 +10,437 @@ from .api import knitro from .package import Package +from .typing import ( + Atom, + BoundType, + Callback, + Request, + Result, + StructureType, + UnreachableError, + ValueType, +) from .utils import NonlinearExpressionData -from .solution import LoadType -class Engine: - """ - A wrapper around the KNITRO API for a single optimization problem. +class _Callback(Protocol): + _atom: Atom + + def func(self, req: Request, res: Result) -> int: ... + def grad(self, req: Request, res: Result) -> int: ... + def hess(self, req: Request, res: Result) -> int: ... + + def expand(self) -> Callback: + return Callback(*map(self._expand, (self.func, self.grad, self.hess))) + + @staticmethod + def _expand(proc: Callable[[Request, Result], int]): + def _expanded( + kc: Any, cb: Any, req: Request, res: Result, user_data: Any = None + ) -> int: + return proc(req, res) + + return _expanded + + +class _ObjectiveCallback(_Callback): + def __init__(self, atom: Atom) -> None: + self._atom = atom + + def func(self, req: Request, res: Result) -> int: + res.obj = self._atom.func(req.x) + return 0 + + def grad(self, req: Request, res: Result) -> int: + res.objGrad[:] = self._atom.grad(req.x) + return 0 + + def hess(self, req: Request, res: Result) -> int: + res.hess[:] = self._atom.hess(req.x, req.sigma) + return 0 + + +class _ConstraintCallback(_Callback): + i: int + + def __init__(self, i: int, atom: Atom) -> None: + self.i = i + self._atom = atom + + def func(self, req: Request, res: Result) -> int: + res.c[:] = [self._atom.func(req.x)] + return 0 + + def grad(self, req: Request, res: Result) -> int: + res.jac[:] = self._atom.grad(req.x) + return 0 + + def hess(self, req: Request, res: Result) -> int: + res.hess[:] = self._atom.hess(req.x, req.lambda_[self.i]) + return 0 + + +def parse_bounds( + items: Union[Iterable[VarData], Iterable[ConstraintData]], + idx_map: Mapping[int, int], +) -> Mapping[BoundType, MutableMapping[int, float]]: + bounds_map = {bnd_type: {} for bnd_type in BoundType} + for item in items: + i = idx_map[id(item)] + if isinstance(item, VarData): + if item.fixed: + bounds_map[BoundType.EQ][i] = value(item.value) + continue + if item.has_lb(): + bounds_map[BoundType.LO][i] = value(item.lb) + if item.has_ub(): + bounds_map[BoundType.UP][i] = value(item.ub) + elif isinstance(item, ConstraintData): + if item.equality: + bounds_map[BoundType.EQ][i] = value(item.lower) + continue + if item.has_lb(): + bounds_map[BoundType.LO][i] = value(item.lower) + if item.has_ub(): + bounds_map[BoundType.UP][i] = value(item.upper) + return bounds_map + + +def parse_var_types( + variables: Iterable[VarData], idx_map: Mapping[int, int] +) -> Mapping[int, int]: + var_types = {} + for var in variables: + i = idx_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 var.is_continuous(): + var_types[i] = knitro.KN_VARTYPE_CONTINUOUS + else: + raise ValueError(f"Unsupported variable type for variable {var.name}.") + return var_types - 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. - """ - mapping: Mapping[Type[Union[VarData, ConstraintData]], MutableMapping[int, int]] - nonlinear_map: MutableMapping[Optional[int], NonlinearExpressionData] - differentiation_order: int +def get_param_setter(param_type: int) -> Callable[..., None]: + if param_type == knitro.KN_PARAMTYPE_INTEGER: + return knitro.KN_set_int_param + elif param_type == knitro.KN_PARAMTYPE_FLOAT: + return knitro.KN_set_double_param + elif param_type == knitro.KN_PARAMTYPE_STRING: + return knitro.KN_set_char_param + raise UnreachableError() - _status: Optional[int] - def __init__(self, *, differentiation_order: int = 2): - # VarData: id(var) -> idx_var - # ConstraintData: id(con) -> idx_con - self.mapping = {VarData: {}, ConstraintData: {}} - # None -> objective - # idx_con -> constraint - self.nonlinear_map = {} - self.differentiation_order = differentiation_order +def get_value_getter( + item_type: type, value_type: ValueType +) -> Callable[..., Optional[list[float]]]: + if item_type is VarData: + if value_type == ValueType.PRIMAL: + return knitro.KN_get_var_primal_values + elif value_type == ValueType.DUAL: + return knitro.KN_get_var_dual_values + elif item_type is ConstraintData: + if value_type == ValueType.DUAL: + return knitro.KN_get_con_dual_values + elif value_type == ValueType.PRIMAL: + return knitro.KN_get_con_values + raise UnreachableError() + + +def get_item_adder(item_type: type) -> Callable[..., list[int]]: + if item_type is VarData: + return knitro.KN_add_vars + elif item_type is ConstraintData: + return knitro.KN_add_cons + raise UnreachableError() + + +def get_bound_setter(item_type: type, bound_type: BoundType) -> Callable[..., None]: + if item_type is VarData: + if bound_type == BoundType.EQ: + return knitro.KN_set_var_fxbnds + elif bound_type == BoundType.LO: + return knitro.KN_set_var_lobnds + elif bound_type == BoundType.UP: + return knitro.KN_set_var_upbnds + elif item_type is ConstraintData: + if bound_type == BoundType.EQ: + return knitro.KN_set_con_eqbnds + elif bound_type == BoundType.LO: + return knitro.KN_set_con_lobnds + elif bound_type == BoundType.UP: + return knitro.KN_set_con_upbnds + raise UnreachableError() + + +def get_structure_adder( + is_obj: bool, structure_type: StructureType +) -> Callable[..., None]: + if is_obj: + if structure_type == StructureType.CONSTANT: + return knitro.KN_add_obj_constant + elif structure_type == StructureType.LINEAR: + return knitro.KN_add_obj_linear_struct + elif structure_type == StructureType.QUADRATIC: + return knitro.KN_add_obj_quadratic_struct + else: + if structure_type == StructureType.CONSTANT: + return knitro.KN_add_con_constants + elif structure_type == StructureType.LINEAR: + return knitro.KN_add_con_linear_struct + elif structure_type == StructureType.QUADRATIC: + return knitro.KN_add_con_quadratic_struct + + +class Engine: + """A wrapper around the KNITRO API for a single optimization problem.""" + + var_map: MutableMapping[int, int] + con_map: MutableMapping[int, int] + nonlinear_map: MutableMapping[Optional[int], NonlinearExpressionData] + has_objective: bool + nonlinear_diff_order: int - # Saving the KNITRO context + def __init__(self, *, nonlinear_diff_order: int = 2) -> None: + self.var_map = {} + self.con_map = {} + self.nonlinear_map = {} + self.has_objective = False + self.nonlinear_diff_order = nonlinear_diff_order self._kc = None - # KNITRO status after solve self._status = None - # ============= LIFECYCLE MANAGEMENT ============= - - def __del__(self): + def __del__(self) -> None: self.close() - def renew(self): + def renew(self) -> None: self.close() self._kc = Package.create_context() + self.var_map.clear() + self.con_map.clear() + self.nonlinear_map.clear() + self.has_objective = False # TODO: remove this when the tolerance tests are fixed in test_solvers tol = 1e-8 self.set_options(ftol=tol, opttol=tol, xtol=tol) - def close(self): + def close(self) -> None: if self._kc is not None: - self._execute(knitro.KN_free) + self.execute(knitro.KN_free) self._kc = None - # ============= PROBLEM BUILDING ================= + T = TypeVar("T") + + def execute(self, api_func: Callable[..., T], *args, **kwargs) -> T: + if self._kc is None: + msg = "KNITRO context has not been initialized or has been freed." + raise RuntimeError(msg) + return api_func(self._kc, *args, **kwargs) - def add_vars(self, variables: Iterable[VarData]): - self._add_items(VarData, variables) - self._set_var_types(variables) - self._set_bnds(VarData, variables) + def add_vars(self, variables: Sequence[VarData]) -> None: + self.add_items(VarData, variables, self.var_map) + self.set_var_types(variables) + self.set_bounds(VarData, variables, self.var_map) - def add_cons(self, cons: Iterable[ConstraintData]): - self._add_items(ConstraintData, cons) - self._set_bnds(ConstraintData, cons) + def add_cons(self, cons: Sequence[ConstraintData]) -> None: + self.add_items(ConstraintData, cons, self.con_map) + self.set_bounds(ConstraintData, cons, self.con_map) for con in cons: - i = self.mapping[ConstraintData][id(con)] - self._add_structs(i, con.body) - - def set_obj(self, obj: ObjectiveData): - self._set_obj_goal(obj.sense) - self._add_structs(None, obj.expr) + i = self.con_map[id(con)] + self.add_structures(i, con.body) - # ============= CONFIGURATION =================== + def set_obj(self, obj: ObjectiveData) -> None: + self.has_objective = True + self.set_obj_goal(obj.sense) + self.add_structures(None, obj.expr) - def set_options(self, **options): + def set_options(self, **options) -> None: 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) - func = self.api_set_param(param_type) - self._execute(func, param_id, val) + param_id = self.execute(knitro.KN_get_param_id, param) + param_type = self.execute(knitro.KN_get_param_type, param_id) + func = get_param_setter(param_type) + self.execute(func, param_id, val) - def set_outlev(self, level: Optional[int] = None): + def set_outlev(self, level: Optional[int] = None) -> None: if level is None: level = knitro.KN_OUTLEV_ALL self.set_options(outlev=level) - def set_time_limit(self, time_limit: float): + def set_time_limit(self, time_limit: float) -> None: self.set_options(maxtime_cpu=time_limit) - def set_num_threads(self, nthreads: int): + def set_num_threads(self, nthreads: int) -> None: self.set_options(threads=nthreads) - # ============= SOLVING ========================= - def solve(self) -> int: - self._register_callbacks() - self._status = self._execute(knitro.KN_solve) + self.register_callbacks() + self._status = self.execute(knitro.KN_solve) return self._status - # ============= INDEX RETRIEVAL ================= - - def get_idx_vars(self, variables: Iterable[VarData]) -> List[int]: - return self._get_idxs(VarData, variables) - - def get_idx_cons(self, constraints: Iterable[ConstraintData]) -> List[int]: - return self._get_idxs(ConstraintData, constraints) - - # ============= SOLUTION RETRIEVAL ============== + def get_idx_vars(self, variables: Iterable[VarData]) -> list[int]: + return self.get_idxs(VarData, variables) def get_status(self) -> int: 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) + raise RuntimeError("Solver has not been run, so no status is available.") return self._status def get_num_iters(self) -> int: - return self._execute(knitro.KN_get_number_iters) + return self.execute(knitro.KN_get_number_iters) def get_num_solutions(self) -> int: - _, _, x, _ = self._execute(knitro.KN_get_solution) + _, _, 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) + return self.execute(knitro.KN_get_solve_time_real) def get_obj_value(self) -> Optional[float]: - return self._execute(knitro.KN_get_obj_value) - - def get_values( - self, item_type: Type[LoadType], items: Iterable[LoadType], *, is_dual: bool - ): - func = self.api_get_values(item_type, is_dual) - idxs = self._get_idxs(item_type, items) - return self._execute(func, idxs) - - # ============= PRIVATE UTILITIES =============== - - def _execute(self, api_func, *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_func(self._kc, *args, **kwargs) - - # ======== PRIVATE COMPONENT MANAGEMENT ========= - - def _get_idxs(self, item_type: Type[LoadType], items: Iterable[LoadType]): - imap = self.mapping[item_type] - return [imap[id(item)] for item in items] - - def _add_items(self, item_type: Type[LoadType], items: Iterable[LoadType]): - func = self.api_add_items(item_type) - items_seq = list(items) - idxs = self._execute(func, len(items_seq)) - if idxs is not None: - self.mapping[item_type].update(zip(map(id, items_seq), idxs)) - - # ====== PRIVATE VARIABLE TYPE HANDLING ========= - - def _set_var_types(self, variables: Iterable[VarData]): - var_types = {} - func = knitro.KN_set_var_types - for var in variables: - i = self.mapping[VarData][id(var)] - self._parse_var_type(var, i, var_types) - self._execute(func, var_types.keys(), var_types.values()) - - def _parse_var_type( - self, var: VarData, i: int, var_types: MutableMapping[int, int] - ) -> int: - if var.is_binary(): - var_types[i] = knitro.KN_VARTYPE_BINARY - elif var.is_integer(): - var_types[i] = knitro.KN_VARTYPE_INTEGER - elif var.is_continuous(): - var_types[i] = knitro.KN_VARTYPE_CONTINUOUS - else: - msg = f"Unsupported variable type for variable {var.name}." - raise ValueError(msg) - - # ========== PRIVATE BOUNDS HANDLING ============ - - class _BndType(Enum): - EQ = 0 - LO = 1 - UP = 2 - - def _get_parse_bnds(self, item_type: Type[LoadType]): + if not self.has_objective: + return None + if self._status not in { + knitro.KN_RC_OPTIMAL, + knitro.KN_RC_OPTIMAL_OR_SATISFACTORY, + knitro.KN_RC_NEAR_OPT, + knitro.KN_RC_ITER_LIMIT_FEAS, + knitro.KN_RC_FEAS_NO_IMPROVE, + knitro.KN_RC_TIME_LIMIT_FEAS, + }: + return None + return self.execute(knitro.KN_get_obj_value) + + def get_idxs( + self, item_type: type, items: Union[Iterable[VarData], Iterable[ConstraintData]] + ) -> list[int]: if item_type is VarData: - return self._parse_var_bnds + return [self.var_map[id(var)] for var in items] elif item_type is ConstraintData: - return self._parse_con_bnds - - def _set_bnds(self, item_type: Type[LoadType], items: Iterable[LoadType]): - parse = self._get_parse_bnds(item_type) - bnds_map = {bnd_type: {} for bnd_type in self._BndType} - imap = self.mapping[item_type] - for item in items: - parse(item, imap[id(item)], bnds_map) + return [self.con_map[id(con)] for con in items] + raise UnreachableError() - for bnd_type, bnds in bnds_map.items(): - if bnds: - func = self.api_set_bnds(item_type, bnd_type) - self._execute(func, bnds.keys(), bnds.values()) - - def _parse_var_bnds( - self, - var: VarData, - i: int, - bnds_map: Mapping[_BndType, MutableMapping[int, float]], - ): - if var.fixed: - bnds_map[Engine._BndType.EQ][i] = value(var.value) - else: - if var.has_lb(): - bnds_map[Engine._BndType.LO][i] = value(var.lb) - if var.has_ub(): - bnds_map[Engine._BndType.UP][i] = value(var.ub) - - def _parse_con_bnds( + def get_values( self, - con: ConstraintData, - i: int, - bnds_map: Mapping[_BndType, MutableMapping[int, float]], - ): - if con.equality: - bnds_map[Engine._BndType.EQ][i] = value(con.lower) - else: - if con.has_lb(): - bnds_map[Engine._BndType.LO][i] = value(con.lower) - if con.has_ub(): - bnds_map[Engine._BndType.UP][i] = value(con.upper) - - # ===== PRIVATE OBJECTIVE GOAL HANDLING ========= - - def _set_obj_goal(self, sense: ObjectiveSense): + item_type: type, + value_type: ValueType, + items: Union[Sequence[VarData], Sequence[ConstraintData]], + ) -> Optional[list[float]]: + getter = get_value_getter(item_type, value_type) + idxs = self.get_idxs(item_type, items) + return self.execute(getter, idxs) + + def set_obj_goal(self, sense: ObjectiveSense) -> None: obj_goal = ( knitro.KN_OBJGOAL_MINIMIZE if sense == ObjectiveSense.minimize else knitro.KN_OBJGOAL_MAXIMIZE ) - self._execute(knitro.KN_set_obj_goal, obj_goal) + self.execute(knitro.KN_set_obj_goal, obj_goal) - # ======= PRIVATE STRUCTURE BUILDING ============ + def add_items( + self, + item_type: type, + items: Union[Sequence[VarData], Sequence[ConstraintData]], + idx_map: MutableMapping[int, int], + ) -> None: + func = get_item_adder(item_type) + idxs = self.execute(func, len(items)) + if idxs is not None: + idx_map.update(zip(map(id, items), idxs)) - def _add_structs(self, i: Optional[int], expr): + def set_bounds( + self, + item_type: type, + items: Union[Sequence[VarData], Sequence[ConstraintData]], + idx_map: MutableMapping[int, int], + ) -> None: + bounds_map = parse_bounds(items, idx_map) + for bound_type, bounds in bounds_map.items(): + if not bounds: + continue + + func = get_bound_setter(item_type, bound_type) + self.execute(func, bounds.keys(), bounds.values()) + + def set_var_types(self, variables: Iterable[VarData]) -> None: + var_types = parse_var_types(variables, self.var_map) + if var_types: + self.execute(knitro.KN_set_var_types, var_types.keys(), var_types.values()) + + def add_structures(self, i: Optional[int], expr) -> None: repn = generate_standard_repn(expr) + if repn is None: + return is_obj = i is None base_args = () if is_obj else (i,) - funcs, args_seq = [], [] if repn.constant is not None: - func = self.api_add_constant(is_obj) - funcs.append(func) - args_seq.append((repn.constant,)) + args = (repn.constant,) + func = get_structure_adder(is_obj, StructureType.CONSTANT) + self.execute(func, *base_args, *args) + if repn.linear_vars: - func = self.api_add_linear_struct(is_obj) idx_lin_vars = self.get_idx_vars(repn.linear_vars) lin_coefs = list(repn.linear_coefs) - funcs.append(func) - args_seq.append((idx_lin_vars, lin_coefs)) + args = (idx_lin_vars, lin_coefs) + func = get_structure_adder(is_obj, StructureType.LINEAR) + self.execute(func, *base_args, *args) + if repn.quadratic_vars: - func = self.api_add_quadratic_struct(is_obj) quad_vars1, quad_vars2 = zip(*repn.quadratic_vars) idx_quad_vars1 = self.get_idx_vars(quad_vars1) idx_quad_vars2 = self.get_idx_vars(quad_vars2) quad_coefs = list(repn.quadratic_coefs) - funcs.append(func) - args_seq.append((idx_quad_vars1, idx_quad_vars2, quad_coefs)) - - for func, args in zip(funcs, args_seq): - self._execute(func, *base_args, *args) + args = (idx_quad_vars1, idx_quad_vars2, quad_coefs) + func = get_structure_adder(is_obj, StructureType.QUADRATIC) + self.execute(func, *base_args, *args) if repn.nonlinear_expr is not None: self.nonlinear_map[i] = NonlinearExpressionData( repn.nonlinear_expr, repn.nonlinear_vars, - differentiation_order=self.differentiation_order, + var_map=self.var_map, + diff_order=self.nonlinear_diff_order, ) - # ======= PRIVATE CALLBACK HANDLING ============= - - def _register_callbacks(self): + def register_callbacks(self) -> None: for i, expr in self.nonlinear_map.items(): - self._register_callback(i, expr) - - def _register_callback(self, i: Optional[int], expr: NonlinearExpressionData): - callback = self._add_callback(knitro.KN_RC_EVALFC, i, expr) - if expr.grad is not None: - self._add_callback(knitro.KN_RC_EVALGA, i, expr, callback) - if expr.hess is not None: - self._add_callback(knitro.KN_RC_EVALH, i, expr, callback) - - def _add_callback( - self, eval_type: int, i: Optional[int], expr: NonlinearExpressionData, *args - ): - func = self.api_add_callback(eval_type) - func_callback = self._build_callback(eval_type, i, expr) - - if eval_type == knitro.KN_RC_EVALH: - hess_vars1, hess_vars2 = zip(*expr.hess_vars) - hess_idx_vars1 = self.get_idx_vars(hess_vars1) - hess_idx_vars2 = self.get_idx_vars(hess_vars2) - args += (hess_idx_vars1, hess_idx_vars2) - elif eval_type == knitro.KN_RC_EVALGA: - idx_vars = self.get_idx_vars(expr.grad_vars) - is_obj = i is None - obj_grad_idx_vars = idx_vars if is_obj else None - jac_idx_cons = [i] * len(idx_vars) if not is_obj else None - jac_idx_vars = idx_vars if not is_obj else None - args += (obj_grad_idx_vars, jac_idx_cons, jac_idx_vars) - elif eval_type == knitro.KN_RC_EVALFC: - eval_obj = i is None - idx_cons = [i] if not eval_obj else None - args += (eval_obj, idx_cons) + self.register_callback(i, expr) - return self._execute(func, *args, func_callback) - - def _build_callback( - self, eval_type: int, i: Optional[int], expr: NonlinearExpressionData - ): + def register_callback( + self, i: Optional[int], expr: NonlinearExpressionData + ) -> None: is_obj = i is None - func = self._get_evaluator(eval_type, expr) - - if is_obj and eval_type == knitro.KN_RC_EVALFC: - - def _callback(req, res): - res.obj = func(req.x) - return 0 - - elif is_obj and eval_type == knitro.KN_RC_EVALGA: - - def _callback(req, res): - res.objGrad[:] = func(req.x) - return 0 - - elif is_obj and eval_type == knitro.KN_RC_EVALH: - - def _callback(req, res): - res.hess[:] = func(req.x, req.sigma) - return 0 - - elif eval_type == knitro.KN_RC_EVALFC: - - def _callback(req, res): - res.c[:] = [func(req.x)] - return 0 - - elif eval_type == knitro.KN_RC_EVALGA: - - def _callback(req, res): - res.jac[:] = func(req.x) - return 0 - - elif eval_type == knitro.KN_RC_EVALH: - - def _callback(req, res): - res.hess[:] = func(req.x, req.lambda_[i]) - return 0 - - return lambda *args: _callback(args[2], args[3]) - - def _get_evaluator(self, eval_type: int, expr: NonlinearExpressionData): - vmap = self.mapping[VarData] - if eval_type == knitro.KN_RC_EVALH: - func = expr.create_hessian_evaluator - elif eval_type == knitro.KN_RC_EVALGA: - func = expr.create_gradient_evaluator - elif eval_type == knitro.KN_RC_EVALFC: - func = expr.create_evaluator - return func(vmap) - # ========= API FUNCTION GETTERS ================ - - def api_set_param(self, param_type: int): - if param_type == knitro.KN_PARAMTYPE_INTEGER: - return knitro.KN_set_int_param - elif param_type == knitro.KN_PARAMTYPE_FLOAT: - return knitro.KN_set_double_param - elif param_type == knitro.KN_PARAMTYPE_STRING: - return knitro.KN_set_char_param - - def api_add_items(self, item_type: Type[LoadType]): - if item_type is VarData: - return knitro.KN_add_vars - elif item_type is ConstraintData: - return knitro.KN_add_cons - - def api_get_values(self, item_type: Type[LoadType], is_dual: bool): - if item_type is VarData and not is_dual: - return knitro.KN_get_var_primal_values - elif item_type is VarData and is_dual: - return knitro.KN_get_var_dual_values - elif item_type is ConstraintData and is_dual: - return knitro.KN_get_con_dual_values - elif item_type is ConstraintData and not is_dual: - return knitro.KN_get_con_values - - def api_set_bnds(self, item_type: Type[LoadType], bnd_type: _BndType): - if item_type is VarData and bnd_type == Engine._BndType.EQ: - return knitro.KN_set_var_fxbnds - elif item_type is VarData and bnd_type == Engine._BndType.LO: - return knitro.KN_set_var_lobnds - elif item_type is VarData and bnd_type == Engine._BndType.UP: - return knitro.KN_set_var_upbnds - elif item_type is ConstraintData and bnd_type == Engine._BndType.EQ: - return knitro.KN_set_con_eqbnds - elif item_type is ConstraintData and bnd_type == Engine._BndType.LO: - return knitro.KN_set_con_lobnds - elif item_type is ConstraintData and bnd_type == Engine._BndType.UP: - return knitro.KN_set_con_upbnds - - def api_add_constant(self, is_obj: bool): if is_obj: - return knitro.KN_add_obj_constant + callback = _ObjectiveCallback(expr).expand() else: - return knitro.KN_add_con_constants + callback = _ConstraintCallback(i, expr).expand() - def api_add_linear_struct(self, is_obj: bool): - if is_obj: - return knitro.KN_add_obj_linear_struct - else: - return knitro.KN_add_con_linear_struct + idx_cons = [i] if not is_obj else None + cb = self.execute(knitro.KN_add_eval_callback, is_obj, idx_cons, callback.func) - def api_add_quadratic_struct(self, is_obj: bool): - if is_obj: - return knitro.KN_add_obj_quadratic_struct - else: - return knitro.KN_add_con_quadratic_struct + if expr.diff_order >= 1: + idx_vars = self.get_idx_vars(expr.grad_vars) + obj_grad_idx_vars = idx_vars if is_obj else None + jac_idx_cons = [i] * len(idx_vars) if not is_obj else None + jac_idx_vars = idx_vars if not is_obj else None + self.execute( + knitro.KN_set_cb_grad, + cb, + obj_grad_idx_vars, + jac_idx_cons, + jac_idx_vars, + callback.grad, + ) - def api_add_callback(self, eval_type: int): - if eval_type == knitro.KN_RC_EVALH: - return knitro.KN_set_cb_hess - elif eval_type == knitro.KN_RC_EVALGA: - return knitro.KN_set_cb_grad - elif eval_type == knitro.KN_RC_EVALFC: - return knitro.KN_add_eval_callback + if expr.diff_order >= 2: + hess_vars1, hess_vars2 = zip(*expr.hess_vars) + hess_idx_vars1 = self.get_idx_vars(hess_vars1) + hess_idx_vars2 = self.get_idx_vars(hess_vars2) + self.execute( + knitro.KN_set_cb_hess, cb, hess_idx_vars1, hess_idx_vars2, callback.hess + ) diff --git a/pyomo/contrib/solver/solvers/knitro/package.py b/pyomo/contrib/solver/solvers/knitro/package.py index 2feebd204cf..dae1bf573eb 100644 --- a/pyomo/contrib/solver/solvers/knitro/package.py +++ b/pyomo/contrib/solver/solvers/knitro/package.py @@ -10,7 +10,7 @@ # ___________________________________________________________________________ import io -from typing import Tuple +from typing import Optional from pyomo.common.tee import TeeStream, capture_output from pyomo.contrib.solver.common.base import Availability @@ -41,7 +41,7 @@ def initialize_license(): return Package._license_context @staticmethod - def release_license(): + def release_license() -> None: """ Release the global KNITRO license context if it exists. """ @@ -61,14 +61,15 @@ def create_context(): return knitro.KN_new_lm(lmc) @staticmethod - def get_version() -> Tuple[int, int, int]: + 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. + tuple[int, int, int]: The (major, minor, patch) version of KNITRO. """ - return tuple(int(x) for x in KNITRO_VERSION.split(".")) + major, minor, patch = map(int, KNITRO_VERSION.split(".")) + return major, minor, patch @staticmethod def check_availability() -> Availability: @@ -78,11 +79,11 @@ def check_availability() -> Availability: Returns: Availability: The availability status (FullLicense, BadLicense, NotFound). """ - if not KNITRO_AVAILABLE: + if not bool(KNITRO_AVAILABLE): return Availability.NotFound try: stream = io.StringIO() - with capture_output(TeeStream(stream), capture_fd=1): + with capture_output(TeeStream(stream), capture_fd=True): kc = Package.create_context() knitro.KN_free(kc) # TODO: parse the stream to check the license type. @@ -92,9 +93,9 @@ def check_availability() -> Availability: class PackageChecker: - _available_cache: Availability + _available_cache: Optional[Availability] - def __init__(self): + def __init__(self) -> None: self._available_cache = None def available(self) -> Availability: @@ -102,5 +103,5 @@ def available(self) -> Availability: self._available_cache = Package.check_availability() return self._available_cache - def version(self): + def version(self) -> tuple[int, int, int]: return Package.get_version() diff --git a/pyomo/contrib/solver/solvers/knitro/solution.py b/pyomo/contrib/solver/solvers/knitro/solution.py index 5dd5d0d5890..52f19e58416 100644 --- a/pyomo/contrib/solver/solvers/knitro/solution.py +++ b/pyomo/contrib/solver/solvers/knitro/solution.py @@ -10,7 +10,7 @@ # ___________________________________________________________________________ from collections.abc import Mapping, Sequence -from typing import Optional, Protocol, Type, TypeVar, Union +from typing import Optional, Protocol, Union, overload from pyomo.common.errors import PyomoException from pyomo.contrib.solver.common.solution_loader import SolutionLoaderBase @@ -22,28 +22,35 @@ from pyomo.core.base.constraint import ConstraintData from pyomo.core.base.var import VarData -LoadType = TypeVar("LoadType", bound=Union[VarData, ConstraintData]) +from .typing import UnreachableError, ValueType class SolutionProvider(Protocol): - def get_error_type( - self, load_type: Type[LoadType], *, is_dual: bool - ) -> Type[PyomoException]: - if load_type is VarData and not is_dual: + @staticmethod + def get_error_type(item_type: type, value_type: ValueType) -> type[PyomoException]: + if item_type is VarData and value_type == ValueType.PRIMAL: return NoSolutionError - elif load_type is VarData and is_dual: + elif item_type is VarData and value_type == ValueType.DUAL: return NoReducedCostsError - elif load_type is ConstraintData and is_dual: + elif item_type is ConstraintData and value_type == ValueType.DUAL: return NoDualsError + raise UnreachableError() def get_num_solutions(self) -> int: ... + @overload def get_values( self, - load_type: Type[LoadType], - to_load: Optional[Sequence[LoadType]], - *, - is_dual: bool, - ) -> Mapping[LoadType, float]: ... + item_type: type[VarData], + value_type: ValueType, + items: Optional[Sequence[VarData]] = None, + ) -> Mapping[VarData, float]: ... + @overload + def get_values( + self, + item_type: type[ConstraintData], + value_type: ValueType, + items: Optional[Sequence[ConstraintData]] = None, + ) -> Mapping[ConstraintData, float]: ... class SolutionLoader(SolutionLoaderBase): @@ -71,23 +78,22 @@ def get_number_of_solutions(self) -> int: def get_values( self, - load_type: Type[LoadType], - to_load: Optional[Sequence[LoadType]], + item_type: type, + value_type: ValueType, + items: Optional[Union[Sequence[VarData], Sequence[ConstraintData]]] = None, *, - is_dual: bool, exists: bool, - ) -> Mapping[LoadType, float]: + ): if not exists: - error_type = self._provider.get_error_type(load_type, is_dual=is_dual) + error_type = SolutionProvider.get_error_type(item_type, value_type) raise error_type() - - return self._provider.get_values(load_type, to_load, is_dual=is_dual) + return self._provider.get_values(item_type, value_type, items) def get_vars( self, vars_to_load: Optional[Sequence[VarData]] = None ) -> Mapping[VarData, float]: return self.get_values( - VarData, vars_to_load, is_dual=False, exists=self.has_primals + VarData, ValueType.PRIMAL, vars_to_load, exists=self.has_primals ) # TODO: remove this when the solution loader is fixed. @@ -98,12 +104,12 @@ def get_reduced_costs( self, vars_to_load: Optional[Sequence[VarData]] = None ) -> Mapping[VarData, float]: return self.get_values( - VarData, vars_to_load, is_dual=True, exists=self.has_reduced_costs + VarData, ValueType.DUAL, vars_to_load, exists=self.has_reduced_costs ) def get_duals( self, cons_to_load: Optional[Sequence[ConstraintData]] = None ) -> Mapping[ConstraintData, float]: return self.get_values( - ConstraintData, cons_to_load, is_dual=True, exists=self.has_duals + ConstraintData, ValueType.DUAL, cons_to_load, exists=self.has_duals ) diff --git a/pyomo/contrib/solver/solvers/knitro/typing.py b/pyomo/contrib/solver/solvers/knitro/typing.py new file mode 100644 index 00000000000..a16ea55041e --- /dev/null +++ b/pyomo/contrib/solver/solvers/knitro/typing.py @@ -0,0 +1,74 @@ +# ___________________________________________________________________________ +# +# 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 +from typing import Any, Optional, Protocol + +from pyomo.common.enums import Enum + + +class BoundType(Enum): + EQ = 0 + LO = 1 + UP = 2 + + +class StructureType(Enum): + CONSTANT = 0 + LINEAR = 1 + QUADRATIC = 2 + + +class ValueType(Enum): + PRIMAL = 0 + DUAL = 1 + + @property + def sign(self) -> float: + return -1.0 if self == ValueType.DUAL else 1.0 + + +class Atom(Protocol): + def func(self, x: list[float]) -> float: ... + def grad(self, x: list[float]) -> list[float]: ... + def hess(self, x: list[float], mu: float) -> list[float]: ... + + +class Request(Protocol): + x: list[float] + sigma: float + lambda_: list[float] + + +class Result(Protocol): + obj: float + c: list[float] + objGrad: list[float] + jac: list[float] + hess: list[float] + + +class Callback: + def __init__( + self, + func: Callable[[Any, Any, Request, Result, Optional[Any]], int], + grad: Callable[[Any, Any, Request, Result, Optional[Any]], int], + hess: Callable[[Any, Any, Request, Result, Optional[Any]], int], + ) -> None: + self.func = func + self.grad = grad + self.hess = hess + + +class UnreachableError(Exception): + """Raised when code reaches a theoretically unreachable state.""" + + pass diff --git a/pyomo/contrib/solver/solvers/knitro/utils.py b/pyomo/contrib/solver/solvers/knitro/utils.py index 5d282b18dca..d471225bedc 100644 --- a/pyomo/contrib/solver/solvers/knitro/utils.py +++ b/pyomo/contrib/solver/solvers/knitro/utils.py @@ -9,21 +9,23 @@ # This software is distributed under the 3-clause BSD License. # ___________________________________________________________________________ - -from collections.abc import Iterable, Mapping, MutableSet -from typing import Any, Callable, List, Optional, Tuple +from collections.abc import Iterable, Mapping, MutableSet, Sequence +from typing import Optional from pyomo.common.collections import ComponentMap, ComponentSet 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.expression import Expression 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 +from .typing import Atom + -def get_active_objectives(block: BlockData) -> List[ObjectiveData]: +def get_active_objectives(block: BlockData) -> list[ObjectiveData]: """ Retrieve all active ObjectiveData objects from a Pyomo Block. @@ -31,7 +33,7 @@ def get_active_objectives(block: BlockData) -> List[ObjectiveData]: block (BlockData): The Pyomo block to search for objectives. Returns: - List[ObjectiveData]: A sorted list of all active objectives in the block. + 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 @@ -39,7 +41,7 @@ def get_active_objectives(block: BlockData) -> List[ObjectiveData]: return list(generator) -def get_active_constraints(block: BlockData) -> List[ConstraintData]: +def get_active_constraints(block: BlockData) -> list[ConstraintData]: """ Retrieve all active ConstraintData objects from a Pyomo Block. @@ -47,7 +49,7 @@ def get_active_constraints(block: BlockData) -> List[ConstraintData]: block (BlockData): The Pyomo block to search for constraints. Returns: - List[ConstraintData]: A sorted list of all active constraints in the block. + 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 @@ -63,17 +65,17 @@ class Problem: 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]): 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] + objs: list[ObjectiveData] + cons: list[ConstraintData] + variables: list[VarData] _vars: MutableSet[VarData] - def __init__(self, block: Optional[BlockData] = None): + def __init__(self, block: Optional[BlockData] = None) -> None: """ Initialize a Problem instance. @@ -88,14 +90,14 @@ def __init__(self, block: Optional[BlockData] = None): if block is not None: self.add_block(block) - def clear(self): + def clear(self) -> None: """Clear all objectives, constraints, and variables from the problem.""" self.objs.clear() self.cons.clear() self.variables.clear() self._vars.clear() - def set_block(self, block: BlockData): + def set_block(self, block: BlockData) -> None: """ Replace the current problem data with data from a new block. @@ -105,7 +107,7 @@ def set_block(self, block: BlockData): self.clear() self.add_block(block) - def add_block(self, block: BlockData): + def add_block(self, block: BlockData) -> None: """ Add objectives, constraints, and variables from a block to the problem. @@ -120,18 +122,37 @@ def add_block(self, block: BlockData): # Collect variables from objectives for obj in new_objs: _, variables, _, _ = collect_vars_and_named_exprs(obj.expr) - self._vars.update(variables) + for var in variables: + self._vars.add(var) # Collect variables from constraints for con in new_cons: _, variables, _, _ = collect_vars_and_named_exprs(con.body) - self._vars.update(variables) + for var in variables: + self._vars.add(var) # Update the variables list with unique variables only self.variables = list(self._vars) -class NonlinearExpressionData: +def set_var_values( + variables: Iterable[VarData], values: Sequence[float], var_map: Mapping[int, int] +) -> None: + """ + Set the values of a list of Pyomo variables from a list of values. + + Args: + variables (Iterable[VarData]): The variables to set. + values (list[float]): The list of values to assign to the variables. + var_map (Mapping[int, int]): A mapping from variable id to index in the + values list. + """ + for var in variables: + i = var_map[id(var)] + var.set_value(values[i]) + + +class NonlinearExpressionData(Atom): """ Holds the data required to evaluate a non-linear expression. @@ -139,92 +160,86 @@ class NonlinearExpressionData: gradient and Hessian information for use with optimization solvers. 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 expressions mapped by variable. - hess (Optional[Mapping[Tuple[VarData, VarData], Any]]): Hessian expressions + variables (list[VarData]): list of variables referenced in the expression. + func_expr (Expression): The Pyomo expression representing the non-linear function. + grad_map (Mapping[VarData, Expression]): Gradient expressions mapped by variable. + hess_map (Mapping[tuple[VarData, VarData], Expression]): Hessian expressions mapped by variable pairs. + diff_order (int): Level of differentiation to compute: + - 0: function evaluation only + - 1: function + gradient + - 2: function + gradient + hessian """ - body: Optional[Any] - variables: List[VarData] - grad: Optional[Mapping[VarData, Any]] - hess: Optional[Mapping[Tuple[VarData, VarData], Any]] + variables: list[VarData] + func_expr: Expression + grad_map: Mapping[VarData, Expression] + hess_map: Mapping[tuple[VarData, VarData], Expression] + diff_order: int def __init__( self, - expr: Optional[Any], + expr: Expression, variables: Iterable[VarData], - *, - differentiation_order: int = 0, - ): + var_map: Mapping[int, int], + diff_order: int = 0, + ) -> None: """ Initialize NonlinearExpressionData. Args: - expr (Optional[Any]): The Pyomo expression to evaluate. + expr (Expression): The Pyomo expression to evaluate. variables (Iterable[VarData]): Variables referenced in the expression. - differentiation_order (int): Level of differentiation to compute: + diff_order (int): Level of differentiation to compute: - 0: function evaluation only - 1: function + gradient - - 2: function + gradient + Hessian + - 2: function + gradient + hessian """ - self.body = expr + self.func_expr = expr self.variables = list(variables) - self.grad = None - self.hess = None - if differentiation_order > 0: + self.diff_order = diff_order + self._var_map = var_map + if diff_order >= 1: self.compute_gradient() - if differentiation_order > 1: + if diff_order >= 2: self.compute_hessian() @property - def grad_vars(self) -> List[VarData]: + def grad_vars(self) -> list[VarData]: """ Get the list of variables for which gradients are available. Returns: - List[VarData]: Variables with gradient information. - - Raises: - ValueError: If gradient information is not available. + list[VarData]: Variables with gradient information. """ - if self.grad is None: - msg = "Gradient information is not available for this expression." - raise ValueError(msg) - return list(self.grad.keys()) + return list(self.grad_map.keys()) @property - def hess_vars(self) -> List[Tuple[VarData, VarData]]: + def hess_vars(self) -> list[tuple[VarData, VarData]]: """ Get the list of variable pairs for which Hessian entries are available. Returns: - List[Tuple[VarData, VarData]]: Variable pairs with Hessian information. + list[tuple[VarData, VarData]]: Variable pairs with Hessian information. - Raises: - ValueError: If Hessian information is not available. """ - if self.hess is None: - msg = "Hessian information is not available for this expression." - raise ValueError(msg) - return list(self.hess.keys()) + return list(self.hess_map.keys()) - def compute_gradient(self): + def compute_gradient(self) -> None: """ Compute gradient expressions for the nonlinear expression. This method computes the gradient of the expression with respect to all variables and stores the results in the grad attribute. """ - derivative = reverse_sd(self.body) + derivative = reverse_sd(self.func_expr) variables = ComponentSet(self.variables) - self.grad = ComponentMap() + self.grad_map = ComponentMap() for v, expr in derivative.items(): if v in variables: - self.grad[v] = expr + self.grad_map[v] = expr - def compute_hessian(self): + def compute_hessian(self) -> None: """ Compute Hessian expressions for the nonlinear expression. @@ -235,100 +250,26 @@ def compute_hessian(self): Note: This method requires that compute_gradient() has been called first. """ - if self.grad is None: - msg = "Gradient must be computed before Hessian. Call compute_gradient() first." - raise ValueError(msg) - variables = ComponentSet(self.variables) - self.hess = ComponentMap() - for v1, grad_expr in self.grad.items(): + self.hess_map = ComponentMap() + for v1, grad_expr in self.grad_map.items(): derivative = reverse_sd(grad_expr) for v2, hess_expr in derivative.items(): if v2 not in variables: continue # Store only upper triangle: ensure var1 <= var2 by ID var1, var2 = (v1, v2) if id(v1) <= id(v2) else (v2, v1) - if (var1, var2) not in self.hess: - self.hess[(var1, var2)] = hess_expr - - def create_evaluator( - self, vmap: Mapping[int, int] - ) -> Callable[[List[float]], float]: - """ - Create a callable evaluator for the non-linear expression. + if (var1, var2) not in self.hess_map: + self.hess_map[(var1, var2)] = hess_expr - 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 func(self, x: list[float]) -> float: + set_var_values(self.variables, x, self._var_map) + return value(self.func_expr) - def _fn(x: List[float]) -> float: - for var in self.variables: - i = vmap[id(var)] - var.set_value(x[i]) - return value(self.body) + def grad(self, x: list[float]) -> list[float]: + set_var_values(self.variables, x, self._var_map) + return [value(g) for g in self.grad_map.values()] - return _fn - - def create_gradient_evaluator( - self, vmap: Mapping[int, int] - ) -> Callable[[List[float]], List[float]]: - """ - 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. - - """ - 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 all variables, not just gradient variables, to ensure consistency - for var in self.variables: - i = vmap[id(var)] - var.set_value(x[i]) - return [value(expr) for expr in self.grad.values()] - - return _grad - - def create_hessian_evaluator( - self, vmap: Mapping[int, int] - ) -> Callable[[List[float], float], List[float]]: - """ - Create a callable Hessian 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], List[float]]: A function that takes a list - of variable values (x) and a multiplier (mu) and returns the scaled - Hessian of the expression as a list of values corresponding to the - variable pairs in self.hess. - - """ - if self.hess is None: - msg = "Hessian information is not available for this expression." - raise ValueError(msg) - - def _hess(x: List[float], mu: float) -> List[float]: - # Set all variables to ensure consistency - for var in self.variables: - i = vmap[id(var)] - var.set_value(x[i]) - return [mu * value(expr) for expr in self.hess.values()] - - return _hess + def hess(self, x: list[float], mu: float) -> list[float]: + set_var_values(self.variables, x, self._var_map) + return [mu * value(h) for h in self.hess_map.values()] From 485001e0535bdf9ed9801884395f5bea0f6a28d4 Mon Sep 17 00:00:00 2001 From: eminyouskn Date: Tue, 23 Sep 2025 09:21:46 -0400 Subject: [PATCH 42/64] Refactor. --- pyomo/contrib/solver/solvers/knitro/base.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/pyomo/contrib/solver/solvers/knitro/base.py b/pyomo/contrib/solver/solvers/knitro/base.py index 44b82219142..039680265c9 100644 --- a/pyomo/contrib/solver/solvers/knitro/base.py +++ b/pyomo/contrib/solver/solvers/knitro/base.py @@ -107,12 +107,12 @@ def _check_available(self) -> None: def _save_var_values(self) -> None: self._saved_var_values.clear() - for var in self.get_vars(): + for var in self._get_vars(): self._saved_var_values[id(var)] = value(var.value) def _restore_var_values(self) -> None: StaleFlagManager.mark_all_as_stale(delayed=True) - for var in self.get_vars(): + for var in self._get_vars(): var.set_value(self._saved_var_values[id(var)]) StaleFlagManager.mark_all_as_stale() @@ -162,10 +162,10 @@ def _postsolve(self, config: Config, timer: HierarchicalTimer) -> Results: return results - def get_vars(self) -> list[VarData]: + def _get_vars(self) -> list[VarData]: return self._problem.variables - def get_items(self, item_type: type): + def _get_items(self, item_type: type): if item_type is VarData: return self._problem.variables elif item_type is ConstraintData: @@ -179,7 +179,7 @@ def get_values( items: Optional[Union[Sequence[VarData], Sequence[ConstraintData]]] = None, ): if items is None: - items = self.get_items(item_type) + items = self._get_items(item_type) x = self._engine.get_values(item_type, value_type, items) if x is None: error_type = SolutionProvider.get_error_type(item_type, value_type) From 5d8847709b69655d0c9c9363c8d4dc8352d6eb11 Mon Sep 17 00:00:00 2001 From: eminyouskn Date: Tue, 23 Sep 2025 15:51:21 -0400 Subject: [PATCH 43/64] Improve solution retreival --- pyomo/contrib/solver/solvers/knitro/base.py | 44 ++++++++--- .../contrib/solver/solvers/knitro/solution.py | 74 ++++++++----------- 2 files changed, 64 insertions(+), 54 deletions(-) diff --git a/pyomo/contrib/solver/solvers/knitro/base.py b/pyomo/contrib/solver/solvers/knitro/base.py index 039680265c9..be7146a8b73 100644 --- a/pyomo/contrib/solver/solvers/knitro/base.py +++ b/pyomo/contrib/solver/solvers/knitro/base.py @@ -16,7 +16,7 @@ from typing import Optional, Union from pyomo.common.collections import ComponentMap -from pyomo.common.errors import ApplicationError +from pyomo.common.errors import ApplicationError, PyomoException from pyomo.common.numeric_types import value from pyomo.common.tee import TeeStream, capture_output from pyomo.common.timing import HierarchicalTimer @@ -28,7 +28,10 @@ ) from pyomo.contrib.solver.common.util import ( IncompatibleModelError, + NoDualsError, NoOptimalSolutionError, + NoReducedCostsError, + NoSolutionError, ) from pyomo.core.base.block import BlockData from pyomo.core.base.constraint import ConstraintData @@ -162,27 +165,24 @@ def _postsolve(self, config: Config, timer: HierarchicalTimer) -> Results: return results - def _get_vars(self) -> list[VarData]: - return self._problem.variables - - def _get_items(self, item_type: type): - if item_type is VarData: - return self._problem.variables - elif item_type is ConstraintData: - return self._problem.cons - raise UnreachableError() - def get_values( self, item_type: type, value_type: ValueType, items: Optional[Union[Sequence[VarData], Sequence[ConstraintData]]] = None, + *, + exists: bool, + solution_id: Optional[int] = None, ): + error_type = self._get_error_type(item_type, value_type) + if not exists: + raise error_type() + # KNITRO only supports a single solution + assert solution_id is None if items is None: items = self._get_items(item_type) x = self._engine.get_values(item_type, value_type, items) if x is None: - error_type = SolutionProvider.get_error_type(item_type, value_type) raise error_type() sign = value_type.sign return ComponentMap([(k, sign * xk) for k, xk in zip(items, x)]) @@ -190,6 +190,16 @@ def get_values( def get_num_solutions(self) -> int: return self._engine.get_num_solutions() + def _get_vars(self) -> list[VarData]: + return self._problem.variables + + def _get_items(self, item_type: type): + if item_type is VarData: + return self._problem.variables + elif item_type is ConstraintData: + return self._problem.cons + raise UnreachableError() + @staticmethod def _get_solution_status(status: int) -> SolutionStatus: if ( @@ -245,3 +255,13 @@ def _get_termination_condition(status: int) -> TerminationCondition: return TerminationCondition.interrupted else: return TerminationCondition.unknown + + @staticmethod + def _get_error_type(item_type: type, value_type: ValueType) -> type[PyomoException]: + if item_type is VarData and value_type == ValueType.PRIMAL: + return NoSolutionError + elif item_type is VarData and value_type == ValueType.DUAL: + return NoReducedCostsError + elif item_type is ConstraintData and value_type == ValueType.DUAL: + return NoDualsError + raise UnreachableError() diff --git a/pyomo/contrib/solver/solvers/knitro/solution.py b/pyomo/contrib/solver/solvers/knitro/solution.py index 52f19e58416..846ce4042ef 100644 --- a/pyomo/contrib/solver/solvers/knitro/solution.py +++ b/pyomo/contrib/solver/solvers/knitro/solution.py @@ -10,31 +10,16 @@ # ___________________________________________________________________________ from collections.abc import Mapping, Sequence -from typing import Optional, Protocol, Union, overload +from typing import Optional, Protocol, overload -from pyomo.common.errors import PyomoException from pyomo.contrib.solver.common.solution_loader import SolutionLoaderBase -from pyomo.contrib.solver.common.util import ( - NoDualsError, - NoReducedCostsError, - NoSolutionError, -) from pyomo.core.base.constraint import ConstraintData from pyomo.core.base.var import VarData -from .typing import UnreachableError, ValueType +from .typing import ValueType class SolutionProvider(Protocol): - @staticmethod - def get_error_type(item_type: type, value_type: ValueType) -> type[PyomoException]: - if item_type is VarData and value_type == ValueType.PRIMAL: - return NoSolutionError - elif item_type is VarData and value_type == ValueType.DUAL: - return NoReducedCostsError - elif item_type is ConstraintData and value_type == ValueType.DUAL: - return NoDualsError - raise UnreachableError() def get_num_solutions(self) -> int: ... @overload @@ -43,6 +28,9 @@ def get_values( item_type: type[VarData], value_type: ValueType, items: Optional[Sequence[VarData]] = None, + *, + exists: bool, + solution_id: Optional[int] = None, ) -> Mapping[VarData, float]: ... @overload def get_values( @@ -50,6 +38,9 @@ def get_values( item_type: type[ConstraintData], value_type: ValueType, items: Optional[Sequence[ConstraintData]] = None, + *, + exists: bool, + solution_id: Optional[int] = None, ) -> Mapping[ConstraintData, float]: ... @@ -76,40 +67,39 @@ def __init__( def get_number_of_solutions(self) -> int: return self._provider.get_num_solutions() - def get_values( - self, - item_type: type, - value_type: ValueType, - items: Optional[Union[Sequence[VarData], Sequence[ConstraintData]]] = None, - *, - exists: bool, - ): - if not exists: - error_type = SolutionProvider.get_error_type(item_type, value_type) - raise error_type() - return self._provider.get_values(item_type, value_type, items) + # 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_vars( - self, vars_to_load: Optional[Sequence[VarData]] = None + self, vars_to_load: Optional[Sequence[VarData]] = None, solution_id=None ) -> Mapping[VarData, float]: - return self.get_values( - VarData, ValueType.PRIMAL, vars_to_load, exists=self.has_primals + return self._provider.get_values( + VarData, + ValueType.PRIMAL, + vars_to_load, + exists=self.has_primals, + solution_id=solution_id, ) - # 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_reduced_costs( - self, vars_to_load: Optional[Sequence[VarData]] = None + self, vars_to_load: Optional[Sequence[VarData]] = None, solution_id=None ) -> Mapping[VarData, float]: - return self.get_values( - VarData, ValueType.DUAL, vars_to_load, exists=self.has_reduced_costs + return self._provider.get_values( + VarData, + ValueType.DUAL, + vars_to_load, + exists=self.has_reduced_costs, + solution_id=solution_id, ) def get_duals( - self, cons_to_load: Optional[Sequence[ConstraintData]] = None + self, cons_to_load: Optional[Sequence[ConstraintData]] = None, solution_id=None ) -> Mapping[ConstraintData, float]: - return self.get_values( - ConstraintData, ValueType.DUAL, cons_to_load, exists=self.has_duals + return self._provider.get_values( + ConstraintData, + ValueType.DUAL, + cons_to_load, + exists=self.has_duals, + solution_id=solution_id, ) From 8022e70903f40a7abcede6a9b0926d04e05e82f7 Mon Sep 17 00:00:00 2001 From: eminyouskn Date: Tue, 23 Sep 2025 16:08:24 -0400 Subject: [PATCH 44/64] Refactor --- pyomo/contrib/solver/solvers/knitro/engine.py | 26 +++++++++---------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/pyomo/contrib/solver/solvers/knitro/engine.py b/pyomo/contrib/solver/solvers/knitro/engine.py index 1aec0fb86ed..91bba342816 100644 --- a/pyomo/contrib/solver/solvers/knitro/engine.py +++ b/pyomo/contrib/solver/solvers/knitro/engine.py @@ -376,26 +376,28 @@ def add_structures(self, i: Optional[int], expr) -> None: is_obj = i is None base_args = () if is_obj else (i,) + structure_type_seq, args_seq = [], [] if repn.constant is not None: - args = (repn.constant,) - func = get_structure_adder(is_obj, StructureType.CONSTANT) - self.execute(func, *base_args, *args) + structure_type_seq += [StructureType.CONSTANT] + args_seq += [(repn.constant,)] if repn.linear_vars: idx_lin_vars = self.get_idx_vars(repn.linear_vars) lin_coefs = list(repn.linear_coefs) - args = (idx_lin_vars, lin_coefs) - func = get_structure_adder(is_obj, StructureType.LINEAR) - self.execute(func, *base_args, *args) + structure_type_seq += [StructureType.LINEAR] + args_seq += [(idx_lin_vars, lin_coefs)] if repn.quadratic_vars: quad_vars1, quad_vars2 = zip(*repn.quadratic_vars) idx_quad_vars1 = self.get_idx_vars(quad_vars1) idx_quad_vars2 = self.get_idx_vars(quad_vars2) quad_coefs = list(repn.quadratic_coefs) - args = (idx_quad_vars1, idx_quad_vars2, quad_coefs) - func = get_structure_adder(is_obj, StructureType.QUADRATIC) + structure_type_seq += [StructureType.QUADRATIC] + args_seq += [(idx_quad_vars1, idx_quad_vars2, quad_coefs)] + + for structure_type, args in zip(structure_type_seq, args_seq): + func = get_structure_adder(is_obj, structure_type) self.execute(func, *base_args, *args) if repn.nonlinear_expr is not None: @@ -414,11 +416,9 @@ def register_callback( self, i: Optional[int], expr: NonlinearExpressionData ) -> None: is_obj = i is None - - if is_obj: - callback = _ObjectiveCallback(expr).expand() - else: - callback = _ConstraintCallback(i, expr).expand() + callback_type = _ObjectiveCallback if is_obj else _ConstraintCallback + callback_args = ((i,) if not is_obj else ()) + (expr,) + callback = callback_type(*callback_args).expand() idx_cons = [i] if not is_obj else None cb = self.execute(knitro.KN_add_eval_callback, is_obj, idx_cons, callback.func) From 5f497895dbde0b1a952091eef164fe00618b0b97 Mon Sep 17 00:00:00 2001 From: eminyouskn Date: Tue, 23 Sep 2025 16:19:16 -0400 Subject: [PATCH 45/64] Refactor --- pyomo/contrib/solver/solvers/knitro/engine.py | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/pyomo/contrib/solver/solvers/knitro/engine.py b/pyomo/contrib/solver/solvers/knitro/engine.py index 91bba342816..40638ef3a1b 100644 --- a/pyomo/contrib/solver/solvers/knitro/engine.py +++ b/pyomo/contrib/solver/solvers/knitro/engine.py @@ -31,7 +31,8 @@ def grad(self, req: Request, res: Result) -> int: ... def hess(self, req: Request, res: Result) -> int: ... def expand(self) -> Callback: - return Callback(*map(self._expand, (self.func, self.grad, self.hess))) + procs = (self.func, self.grad, self.hess) + return Callback(*map(self._expand, procs)) @staticmethod def _expand(proc: Callable[[Request, Result], int]): @@ -247,10 +248,7 @@ def add_vars(self, variables: Sequence[VarData]) -> None: def add_cons(self, cons: Sequence[ConstraintData]) -> None: self.add_items(ConstraintData, cons, self.con_map) self.set_bounds(ConstraintData, cons, self.con_map) - - for con in cons: - i = self.con_map[id(con)] - self.add_structures(i, con.body) + self.set_con_structures(cons) def set_obj(self, obj: ObjectiveData) -> None: self.has_objective = True @@ -367,7 +365,13 @@ def set_bounds( def set_var_types(self, variables: Iterable[VarData]) -> None: var_types = parse_var_types(variables, self.var_map) if var_types: - self.execute(knitro.KN_set_var_types, var_types.keys(), var_types.values()) + func = knitro.KN_set_var_types + self.execute(func, var_types.keys(), var_types.values()) + + def set_con_structures(self, cons: Iterable[ConstraintData]) -> None: + for con in cons: + i = self.con_map[id(con)] + self.add_structures(i, con.body) def add_structures(self, i: Optional[int], expr) -> None: repn = generate_standard_repn(expr) From 1c56aa30e4dcfb915b9b8d3574f079f0c5254449 Mon Sep 17 00:00:00 2001 From: eminyouskn Date: Tue, 23 Sep 2025 16:47:17 -0400 Subject: [PATCH 46/64] Refactor. --- pyomo/contrib/solver/solvers/knitro/engine.py | 23 +++++++++++++------ 1 file changed, 16 insertions(+), 7 deletions(-) diff --git a/pyomo/contrib/solver/solvers/knitro/engine.py b/pyomo/contrib/solver/solvers/knitro/engine.py index 40638ef3a1b..5557ed1a18d 100644 --- a/pyomo/contrib/solver/solvers/knitro/engine.py +++ b/pyomo/contrib/solver/solvers/knitro/engine.py @@ -150,7 +150,7 @@ def get_value_getter( raise UnreachableError() -def get_item_adder(item_type: type) -> Callable[..., list[int]]: +def get_item_adder(item_type: type) -> Callable[..., Optional[list[int]]]: if item_type is VarData: return knitro.KN_add_vars elif item_type is ConstraintData: @@ -198,12 +198,15 @@ def get_structure_adder( class Engine: """A wrapper around the KNITRO API for a single optimization problem.""" + has_objective: bool var_map: MutableMapping[int, int] con_map: MutableMapping[int, int] nonlinear_map: MutableMapping[Optional[int], NonlinearExpressionData] - has_objective: bool nonlinear_diff_order: int + _kc: Optional[Any] + _status: Optional[int] + def __init__(self, *, nonlinear_diff_order: int = 2) -> None: self.var_map = {} self.con_map = {} @@ -253,14 +256,11 @@ def add_cons(self, cons: Sequence[ConstraintData]) -> None: def set_obj(self, obj: ObjectiveData) -> None: self.has_objective = True self.set_obj_goal(obj.sense) - self.add_structures(None, obj.expr) + self.set_obj_structures(obj) def set_options(self, **options) -> None: 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) - func = get_param_setter(param_type) - self.execute(func, param_id, val) + self.set_option(param, val) def set_outlev(self, level: Optional[int] = None) -> None: if level is None: @@ -329,6 +329,12 @@ def get_values( idxs = self.get_idxs(item_type, items) return self.execute(getter, idxs) + def set_option(self, param: str, val) -> None: + param_id = self.execute(knitro.KN_get_param_id, param) + param_type = self.execute(knitro.KN_get_param_type, param_id) + func = get_param_setter(param_type) + self.execute(func, param_id, val) + def set_obj_goal(self, sense: ObjectiveSense) -> None: obj_goal = ( knitro.KN_OBJGOAL_MINIMIZE @@ -373,6 +379,9 @@ def set_con_structures(self, cons: Iterable[ConstraintData]) -> None: i = self.con_map[id(con)] self.add_structures(i, con.body) + def set_obj_structures(self, obj: ObjectiveData) -> None: + self.add_structures(None, obj.expr) + def add_structures(self, i: Optional[int], expr) -> None: repn = generate_standard_repn(expr) if repn is None: From 3f5ab7574609450ad5a033b929f6b11855808eee Mon Sep 17 00:00:00 2001 From: eminyouskn Date: Wed, 24 Sep 2025 01:53:00 -0400 Subject: [PATCH 47/64] Final Refactor. --- pyomo/contrib/solver/solvers/knitro/base.py | 27 ++- pyomo/contrib/solver/solvers/knitro/engine.py | 162 +++++++++--------- .../contrib/solver/solvers/knitro/solution.py | 21 +-- pyomo/contrib/solver/solvers/knitro/typing.py | 8 +- 4 files changed, 103 insertions(+), 115 deletions(-) diff --git a/pyomo/contrib/solver/solvers/knitro/base.py b/pyomo/contrib/solver/solvers/knitro/base.py index be7146a8b73..cb92e86a3ce 100644 --- a/pyomo/contrib/solver/solvers/knitro/base.py +++ b/pyomo/contrib/solver/solvers/knitro/base.py @@ -10,10 +10,10 @@ # ___________________________________________________________________________ from abc import abstractmethod -from collections.abc import Sequence +from collections.abc import Mapping, Sequence from datetime import datetime, timezone from io import StringIO -from typing import Optional, Union +from typing import Optional from pyomo.common.collections import ComponentMap from pyomo.common.errors import ApplicationError, PyomoException @@ -43,7 +43,7 @@ from .engine import Engine from .package import PackageChecker from .solution import SolutionLoader, SolutionProvider -from .typing import UnreachableError, ValueType +from .typing import ItemType, T, UnreachableError, ValueType from .utils import Problem @@ -95,7 +95,7 @@ def solve(self, model: BlockData, **kwds) -> Results: return results def _build_config(self, **kwds) -> Config: - return self.config(value=kwds, preserve_implicit=True) + return self.config(value=kwds, preserve_implicit=True) # type: ignore def _validate_problem(self) -> None: if len(self._problem.objs) > 1: @@ -167,13 +167,13 @@ def _postsolve(self, config: Config, timer: HierarchicalTimer) -> Results: def get_values( self, - item_type: type, + item_type: type[T], value_type: ValueType, - items: Optional[Union[Sequence[VarData], Sequence[ConstraintData]]] = None, + items: Optional[Sequence[T]] = None, *, exists: bool, solution_id: Optional[int] = None, - ): + ) -> Mapping[T, float]: error_type = self._get_error_type(item_type, value_type) if not exists: raise error_type() @@ -193,12 +193,9 @@ def get_num_solutions(self) -> int: def _get_vars(self) -> list[VarData]: return self._problem.variables - def _get_items(self, item_type: type): - if item_type is VarData: - return self._problem.variables - elif item_type is ConstraintData: - return self._problem.cons - raise UnreachableError() + def _get_items(self, item_type: type[T]) -> Sequence[T]: + maps = {VarData: self._problem.variables, ConstraintData: self._problem.cons} + return maps[item_type] @staticmethod def _get_solution_status(status: int) -> SolutionStatus: @@ -257,7 +254,9 @@ def _get_termination_condition(status: int) -> TerminationCondition: return TerminationCondition.unknown @staticmethod - def _get_error_type(item_type: type, value_type: ValueType) -> type[PyomoException]: + def _get_error_type( + item_type: type[ItemType], value_type: ValueType + ) -> type[PyomoException]: if item_type is VarData and value_type == ValueType.PRIMAL: return NoSolutionError elif item_type is VarData and value_type == ValueType.DUAL: diff --git a/pyomo/contrib/solver/solvers/knitro/engine.py b/pyomo/contrib/solver/solvers/knitro/engine.py index 5557ed1a18d..6e8ebcaac83 100644 --- a/pyomo/contrib/solver/solvers/knitro/engine.py +++ b/pyomo/contrib/solver/solvers/knitro/engine.py @@ -1,5 +1,6 @@ from collections.abc import Callable, Iterable, Mapping, MutableMapping, Sequence -from typing import Any, Optional, Protocol, TypeVar, Union +from types import MappingProxyType +from typing import Any, Optional, Protocol, TypeVar from pyomo.common.enums import ObjectiveSense from pyomo.common.numeric_types import value @@ -14,6 +15,8 @@ Atom, BoundType, Callback, + ItemType, + T, Request, Result, StructureType, @@ -82,8 +85,7 @@ def hess(self, req: Request, res: Result) -> int: def parse_bounds( - items: Union[Iterable[VarData], Iterable[ConstraintData]], - idx_map: Mapping[int, int], + items: Iterable[T], idx_map: Mapping[int, int] ) -> Mapping[BoundType, MutableMapping[int, float]]: bounds_map = {bnd_type: {} for bnd_type in BoundType} for item in items: @@ -107,24 +109,26 @@ def parse_bounds( return bounds_map -def parse_var_types( - variables: Iterable[VarData], idx_map: Mapping[int, int] +def parse_types( + items: Iterable[T], idx_map: Mapping[int, int] ) -> Mapping[int, int]: - var_types = {} - for var in variables: - i = idx_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 var.is_continuous(): - var_types[i] = knitro.KN_VARTYPE_CONTINUOUS - else: - raise ValueError(f"Unsupported variable type for variable {var.name}.") - return var_types - - -def get_param_setter(param_type: int) -> Callable[..., None]: + types_map = {} + for item in items: + i = idx_map[id(item)] + if isinstance(item, VarData): + if item.is_binary(): + types_map[i] = knitro.KN_VARTYPE_BINARY + elif item.is_integer(): + types_map[i] = knitro.KN_VARTYPE_INTEGER + elif item.is_continuous(): + types_map[i] = knitro.KN_VARTYPE_CONTINUOUS + else: + msg = f"Variable {item.name} has unsupported type." + raise ValueError(msg) + return types_map + + +def api_set_param(param_type: int) -> Callable[..., None]: if param_type == knitro.KN_PARAMTYPE_INTEGER: return knitro.KN_set_int_param elif param_type == knitro.KN_PARAMTYPE_FLOAT: @@ -134,8 +138,8 @@ def get_param_setter(param_type: int) -> Callable[..., None]: raise UnreachableError() -def get_value_getter( - item_type: type, value_type: ValueType +def api_get_values( + item_type: type[T], value_type: ValueType ) -> Callable[..., Optional[list[float]]]: if item_type is VarData: if value_type == ValueType.PRIMAL: @@ -150,7 +154,7 @@ def get_value_getter( raise UnreachableError() -def get_item_adder(item_type: type) -> Callable[..., Optional[list[int]]]: +def api_add_items(item_type: type[T]) -> Callable[..., Optional[list[int]]]: if item_type is VarData: return knitro.KN_add_vars elif item_type is ConstraintData: @@ -158,7 +162,7 @@ def get_item_adder(item_type: type) -> Callable[..., Optional[list[int]]]: raise UnreachableError() -def get_bound_setter(item_type: type, bound_type: BoundType) -> Callable[..., None]: +def api_set_bnds(item_type: type[T], bound_type: BoundType) -> Callable[..., None]: if item_type is VarData: if bound_type == BoundType.EQ: return knitro.KN_set_var_fxbnds @@ -175,10 +179,12 @@ def get_bound_setter(item_type: type, bound_type: BoundType) -> Callable[..., No return knitro.KN_set_con_upbnds raise UnreachableError() +def api_set_types(item_type: type[T]) -> Callable[..., None]: + if item_type is VarData: + return knitro.KN_set_var_types + raise UnreachableError() -def get_structure_adder( - is_obj: bool, structure_type: StructureType -) -> Callable[..., None]: +def api_add_struct(is_obj: bool, structure_type: StructureType) -> Callable[..., None]: if is_obj: if structure_type == StructureType.CONSTANT: return knitro.KN_add_obj_constant @@ -199,8 +205,7 @@ class Engine: """A wrapper around the KNITRO API for a single optimization problem.""" has_objective: bool - var_map: MutableMapping[int, int] - con_map: MutableMapping[int, int] + maps: Mapping[type[ItemType], MutableMapping[int, int]] nonlinear_map: MutableMapping[Optional[int], NonlinearExpressionData] nonlinear_diff_order: int @@ -208,10 +213,15 @@ class Engine: _status: Optional[int] def __init__(self, *, nonlinear_diff_order: int = 2) -> None: - self.var_map = {} - self.con_map = {} - self.nonlinear_map = {} self.has_objective = False + # Maps: + # VarData -> {id(var): idx in KNITRO} + # ConstraintData -> {id(con): idx in KNITRO} + self.maps = MappingProxyType({VarData: {}, ConstraintData: {}}) + # Nonlinear map: + # None -> objective nonlinear expression + # idx_con -> constranit nonlinear expression + self.nonlinear_map = {} self.nonlinear_diff_order = nonlinear_diff_order self._kc = None self._status = None @@ -222,10 +232,10 @@ def __del__(self) -> None: def renew(self) -> None: self.close() self._kc = Package.create_context() - self.var_map.clear() - self.con_map.clear() - self.nonlinear_map.clear() self.has_objective = False + for item_type in self.maps: + self.maps[item_type].clear() + self.nonlinear_map.clear() # TODO: remove this when the tolerance tests are fixed in test_solvers tol = 1e-8 self.set_options(ftol=tol, opttol=tol, xtol=tol) @@ -235,22 +245,22 @@ def close(self) -> None: self.execute(knitro.KN_free) self._kc = None - T = TypeVar("T") + R = TypeVar("R") - def execute(self, api_func: Callable[..., T], *args, **kwargs) -> T: + def execute(self, api_func: Callable[..., R], *args, **kwargs) -> R: if self._kc is None: msg = "KNITRO context has not been initialized or has been freed." raise RuntimeError(msg) return api_func(self._kc, *args, **kwargs) def add_vars(self, variables: Sequence[VarData]) -> None: - self.add_items(VarData, variables, self.var_map) - self.set_var_types(variables) - self.set_bounds(VarData, variables, self.var_map) + self.add_items(VarData, variables) + self.set_types(VarData, variables) + self.set_bounds(VarData, variables) def add_cons(self, cons: Sequence[ConstraintData]) -> None: - self.add_items(ConstraintData, cons, self.con_map) - self.set_bounds(ConstraintData, cons, self.con_map) + self.add_items(ConstraintData, cons) + self.set_bounds(ConstraintData, cons) self.set_con_structures(cons) def set_obj(self, obj: ObjectiveData) -> None: @@ -310,29 +320,21 @@ def get_obj_value(self) -> Optional[float]: return None return self.execute(knitro.KN_get_obj_value) - def get_idxs( - self, item_type: type, items: Union[Iterable[VarData], Iterable[ConstraintData]] - ) -> list[int]: - if item_type is VarData: - return [self.var_map[id(var)] for var in items] - elif item_type is ConstraintData: - return [self.con_map[id(con)] for con in items] - raise UnreachableError() + def get_idxs(self, item_type: type[T], items: Iterable[T]) -> list[int]: + idx_map = self.maps[item_type] + return [idx_map[id(item)] for item in items] def get_values( - self, - item_type: type, - value_type: ValueType, - items: Union[Sequence[VarData], Sequence[ConstraintData]], + self, item_type: type[T], value_type: ValueType, items: Iterable[T] ) -> Optional[list[float]]: - getter = get_value_getter(item_type, value_type) + func = api_get_values(item_type, value_type) idxs = self.get_idxs(item_type, items) - return self.execute(getter, idxs) + return self.execute(func, idxs) def set_option(self, param: str, val) -> None: param_id = self.execute(knitro.KN_get_param_id, param) param_type = self.execute(knitro.KN_get_param_type, param_id) - func = get_param_setter(param_type) + func = api_set_param(param_type) self.execute(func, param_id, val) def set_obj_goal(self, sense: ObjectiveSense) -> None: @@ -343,40 +345,30 @@ def set_obj_goal(self, sense: ObjectiveSense) -> None: ) self.execute(knitro.KN_set_obj_goal, obj_goal) - def add_items( - self, - item_type: type, - items: Union[Sequence[VarData], Sequence[ConstraintData]], - idx_map: MutableMapping[int, int], - ) -> None: - func = get_item_adder(item_type) + def add_items(self, item_type: type[T], items: Sequence[T]) -> None: + func = api_add_items(item_type) idxs = self.execute(func, len(items)) if idxs is not None: - idx_map.update(zip(map(id, items), idxs)) + self.maps[item_type].update(zip(map(id, items), idxs)) - def set_bounds( - self, - item_type: type, - items: Union[Sequence[VarData], Sequence[ConstraintData]], - idx_map: MutableMapping[int, int], - ) -> None: - bounds_map = parse_bounds(items, idx_map) + def set_bounds(self, item_type: type[T], items: Iterable[T]) -> None: + bounds_map = parse_bounds(items, self.maps[item_type]) for bound_type, bounds in bounds_map.items(): if not bounds: continue - func = get_bound_setter(item_type, bound_type) + func = api_set_bnds(item_type, bound_type) self.execute(func, bounds.keys(), bounds.values()) - def set_var_types(self, variables: Iterable[VarData]) -> None: - var_types = parse_var_types(variables, self.var_map) - if var_types: - func = knitro.KN_set_var_types - self.execute(func, var_types.keys(), var_types.values()) + def set_types(self, item_type: type[T], items: Iterable[T]) -> None: + types_map = parse_types(items, self.maps[item_type]) + if types_map: + func = api_set_types(item_type) + self.execute(func, types_map.keys(), types_map.values()) def set_con_structures(self, cons: Iterable[ConstraintData]) -> None: for con in cons: - i = self.con_map[id(con)] + i = self.maps[ConstraintData][id(con)] self.add_structures(i, con.body) def set_obj_structures(self, obj: ObjectiveData) -> None: @@ -410,14 +402,14 @@ def add_structures(self, i: Optional[int], expr) -> None: args_seq += [(idx_quad_vars1, idx_quad_vars2, quad_coefs)] for structure_type, args in zip(structure_type_seq, args_seq): - func = get_structure_adder(is_obj, structure_type) + func = api_add_struct(is_obj, structure_type) self.execute(func, *base_args, *args) if repn.nonlinear_expr is not None: self.nonlinear_map[i] = NonlinearExpressionData( repn.nonlinear_expr, repn.nonlinear_vars, - var_map=self.var_map, + var_map=self.maps[VarData], diff_order=self.nonlinear_diff_order, ) @@ -429,9 +421,11 @@ def register_callback( self, i: Optional[int], expr: NonlinearExpressionData ) -> None: is_obj = i is None - callback_type = _ObjectiveCallback if is_obj else _ConstraintCallback - callback_args = ((i,) if not is_obj else ()) + (expr,) - callback = callback_type(*callback_args).expand() + if is_obj: + ne = _ObjectiveCallback(expr) + else: + ne = _ConstraintCallback(i, expr) + callback = ne.expand() idx_cons = [i] if not is_obj else None cb = self.execute(knitro.KN_add_eval_callback, is_obj, idx_cons, callback.func) diff --git a/pyomo/contrib/solver/solvers/knitro/solution.py b/pyomo/contrib/solver/solvers/knitro/solution.py index 846ce4042ef..9a4577b4187 100644 --- a/pyomo/contrib/solver/solvers/knitro/solution.py +++ b/pyomo/contrib/solver/solvers/knitro/solution.py @@ -10,38 +10,27 @@ # ___________________________________________________________________________ from collections.abc import Mapping, Sequence -from typing import Optional, Protocol, overload +from typing import Optional, Protocol from pyomo.contrib.solver.common.solution_loader import SolutionLoaderBase from pyomo.core.base.constraint import ConstraintData from pyomo.core.base.var import VarData -from .typing import ValueType +from .typing import T, ValueType class SolutionProvider(Protocol): def get_num_solutions(self) -> int: ... - @overload def get_values( self, - item_type: type[VarData], + item_type: type[T], value_type: ValueType, - items: Optional[Sequence[VarData]] = None, + items: Optional[Sequence[T]] = None, *, exists: bool, solution_id: Optional[int] = None, - ) -> Mapping[VarData, float]: ... - @overload - def get_values( - self, - item_type: type[ConstraintData], - value_type: ValueType, - items: Optional[Sequence[ConstraintData]] = None, - *, - exists: bool, - solution_id: Optional[int] = None, - ) -> Mapping[ConstraintData, float]: ... + ) -> Mapping[T, float]: ... class SolutionLoader(SolutionLoaderBase): diff --git a/pyomo/contrib/solver/solvers/knitro/typing.py b/pyomo/contrib/solver/solvers/knitro/typing.py index a16ea55041e..2bd6bc3967c 100644 --- a/pyomo/contrib/solver/solvers/knitro/typing.py +++ b/pyomo/contrib/solver/solvers/knitro/typing.py @@ -10,9 +10,11 @@ # ___________________________________________________________________________ from collections.abc import Callable -from typing import Any, Optional, Protocol +from typing import Any, Optional, Protocol, TypeVar, Union from pyomo.common.enums import Enum +from pyomo.core.base.constraint import ConstraintData +from pyomo.core.base.var import VarData class BoundType(Enum): @@ -36,6 +38,10 @@ def sign(self) -> float: return -1.0 if self == ValueType.DUAL else 1.0 +ItemType = Union[VarData, ConstraintData] +T = TypeVar("T", bound=ItemType) + + class Atom(Protocol): def func(self, x: list[float]) -> float: ... def grad(self, x: list[float]) -> list[float]: ... From 5041ab643a352f87d7a8ea21ccc66dd4edf458ce Mon Sep 17 00:00:00 2001 From: eminyouskn Date: Wed, 24 Sep 2025 01:56:25 -0400 Subject: [PATCH 48/64] black format --- pyomo/contrib/solver/solvers/knitro/engine.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/pyomo/contrib/solver/solvers/knitro/engine.py b/pyomo/contrib/solver/solvers/knitro/engine.py index 6e8ebcaac83..06885430662 100644 --- a/pyomo/contrib/solver/solvers/knitro/engine.py +++ b/pyomo/contrib/solver/solvers/knitro/engine.py @@ -109,9 +109,7 @@ def parse_bounds( return bounds_map -def parse_types( - items: Iterable[T], idx_map: Mapping[int, int] -) -> Mapping[int, int]: +def parse_types(items: Iterable[T], idx_map: Mapping[int, int]) -> Mapping[int, int]: types_map = {} for item in items: i = idx_map[id(item)] @@ -179,11 +177,13 @@ def api_set_bnds(item_type: type[T], bound_type: BoundType) -> Callable[..., Non return knitro.KN_set_con_upbnds raise UnreachableError() + def api_set_types(item_type: type[T]) -> Callable[..., None]: if item_type is VarData: return knitro.KN_set_var_types raise UnreachableError() + def api_add_struct(is_obj: bool, structure_type: StructureType) -> Callable[..., None]: if is_obj: if structure_type == StructureType.CONSTANT: From b55e8ab60f80fd2334c66e95eb233d3ed9bfb447 Mon Sep 17 00:00:00 2001 From: eminyouskn Date: Wed, 24 Sep 2025 13:16:00 -0400 Subject: [PATCH 49/64] handle mrmundt comments. --- .../contrib/solver/solvers/knitro/__init__.py | 4 +- pyomo/contrib/solver/solvers/knitro/api.py | 3 - pyomo/contrib/solver/solvers/knitro/base.py | 51 +++++----- pyomo/contrib/solver/solvers/knitro/config.py | 2 +- pyomo/contrib/solver/solvers/knitro/direct.py | 11 +-- pyomo/contrib/solver/solvers/knitro/engine.py | 99 ++++++++++++------- .../contrib/solver/solvers/knitro/package.py | 30 +++--- .../contrib/solver/solvers/knitro/solution.py | 9 +- pyomo/contrib/solver/solvers/knitro/typing.py | 20 ++-- pyomo/contrib/solver/solvers/knitro/utils.py | 62 ++++++------ .../tests/solvers/test_knitro_direct.py | 2 +- 11 files changed, 158 insertions(+), 135 deletions(-) diff --git a/pyomo/contrib/solver/solvers/knitro/__init__.py b/pyomo/contrib/solver/solvers/knitro/__init__.py index 9fab0c8f742..8f8db6e272a 100644 --- a/pyomo/contrib/solver/solvers/knitro/__init__.py +++ b/pyomo/contrib/solver/solvers/knitro/__init__.py @@ -11,8 +11,8 @@ from pyomo.contrib.solver.common.factory import SolverFactory -from .config import Config as KnitroConfig -from .direct import Solver as KnitroDirectSolver +from .config import KnitroConfig +from .direct import KnitroDirectSolver __all__ = ["KnitroConfig", "KnitroDirectSolver"] diff --git a/pyomo/contrib/solver/solvers/knitro/api.py b/pyomo/contrib/solver/solvers/knitro/api.py index 0d14643bbb6..4cd4f170f28 100644 --- a/pyomo/contrib/solver/solvers/knitro/api.py +++ b/pyomo/contrib/solver/solvers/knitro/api.py @@ -9,9 +9,6 @@ # This software is distributed under the 3-clause BSD License. # ___________________________________________________________________________ - -# import knitro - from pyomo.common.dependencies import attempt_import knitro, KNITRO_AVAILABLE = attempt_import("knitro") diff --git a/pyomo/contrib/solver/solvers/knitro/base.py b/pyomo/contrib/solver/solvers/knitro/base.py index cb92e86a3ce..684b1f5266e 100644 --- a/pyomo/contrib/solver/solvers/knitro/base.py +++ b/pyomo/contrib/solver/solvers/knitro/base.py @@ -33,26 +33,33 @@ NoReducedCostsError, NoSolutionError, ) +from pyomo.contrib.solver.solvers.knitro.api import knitro +from pyomo.contrib.solver.solvers.knitro.config import KnitroConfig +from pyomo.contrib.solver.solvers.knitro.engine import Engine +from pyomo.contrib.solver.solvers.knitro.package import PackageChecker +from pyomo.contrib.solver.solvers.knitro.solution import ( + SolutionLoader, + SolutionProvider, +) +from pyomo.contrib.solver.solvers.knitro.typing import ( + ItemData, + ItemType, + UnreachableError, + ValueType, +) +from pyomo.contrib.solver.solvers.knitro.utils import ModelCollector 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 .solution import SolutionLoader, SolutionProvider -from .typing import ItemType, T, UnreachableError, ValueType -from .utils import Problem - -class SolverBase(SolutionProvider, PackageChecker, base.SolverBase): - CONFIG = Config() - config: Config +class KnitroSolverBase(SolutionProvider, PackageChecker, base.SolverBase): + CONFIG = KnitroConfig() + config: KnitroConfig _engine: Engine - _problem: Problem + _problem: ModelCollector _stream: StringIO _saved_var_values: dict[int, Optional[float]] @@ -60,7 +67,7 @@ def __init__(self, **kwds) -> None: PackageChecker.__init__(self) base.SolverBase.__init__(self, **kwds) self._engine = Engine() - self._problem = Problem() + self._problem = ModelCollector() self._stream = StringIO() self._saved_var_values = {} @@ -94,7 +101,7 @@ def solve(self, model: BlockData, **kwds) -> Results: results.timing_info.wall_time = (tock - tick).total_seconds() return results - def _build_config(self, **kwds) -> Config: + def _build_config(self, **kwds) -> KnitroConfig: return self.config(value=kwds, preserve_implicit=True) # type: ignore def _validate_problem(self) -> None: @@ -121,15 +128,15 @@ def _restore_var_values(self) -> None: @abstractmethod def _presolve( - self, model: BlockData, config: Config, timer: HierarchicalTimer + self, model: BlockData, config: KnitroConfig, timer: HierarchicalTimer ) -> None: raise NotImplementedError @abstractmethod - def _solve(self, config: Config, timer: HierarchicalTimer) -> None: + def _solve(self, config: KnitroConfig, timer: HierarchicalTimer) -> None: raise NotImplementedError - def _postsolve(self, config: Config, timer: HierarchicalTimer) -> Results: + def _postsolve(self, config: KnitroConfig, timer: HierarchicalTimer) -> Results: status = self._engine.get_status() results = Results() results.solver_name = self.name @@ -167,13 +174,13 @@ def _postsolve(self, config: Config, timer: HierarchicalTimer) -> Results: def get_values( self, - item_type: type[T], + item_type: type[ItemType], value_type: ValueType, - items: Optional[Sequence[T]] = None, + items: Optional[Sequence[ItemType]] = None, *, exists: bool, solution_id: Optional[int] = None, - ) -> Mapping[T, float]: + ) -> Mapping[ItemType, float]: error_type = self._get_error_type(item_type, value_type) if not exists: raise error_type() @@ -193,7 +200,7 @@ def get_num_solutions(self) -> int: def _get_vars(self) -> list[VarData]: return self._problem.variables - def _get_items(self, item_type: type[T]) -> Sequence[T]: + def _get_items(self, item_type: type[ItemType]) -> Sequence[ItemType]: maps = {VarData: self._problem.variables, ConstraintData: self._problem.cons} return maps[item_type] @@ -255,7 +262,7 @@ def _get_termination_condition(status: int) -> TerminationCondition: @staticmethod def _get_error_type( - item_type: type[ItemType], value_type: ValueType + item_type: type[ItemData], value_type: ValueType ) -> type[PyomoException]: if item_type is VarData and value_type == ValueType.PRIMAL: return NoSolutionError diff --git a/pyomo/contrib/solver/solvers/knitro/config.py b/pyomo/contrib/solver/solvers/knitro/config.py index d12f21d5417..bdea4c9662f 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 Config(SolverConfig): +class KnitroConfig(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 fdf55c8b509..579eea7c913 100644 --- a/pyomo/contrib/solver/solvers/knitro/direct.py +++ b/pyomo/contrib/solver/solvers/knitro/direct.py @@ -11,21 +11,20 @@ from pyomo.common.timing import HierarchicalTimer +from pyomo.contrib.solver.solvers.knitro.base import KnitroSolverBase +from pyomo.contrib.solver.solvers.knitro.config import KnitroConfig from pyomo.core.base.block import BlockData -from .base import SolverBase -from .config import Config - -class Solver(SolverBase): +class KnitroDirectSolver(KnitroSolverBase): def _presolve( - self, model: BlockData, config: Config, timer: HierarchicalTimer + self, model: BlockData, config: KnitroConfig, timer: HierarchicalTimer ) -> None: timer.start("build_problem") self._problem.set_block(model) timer.stop("build_problem") - def _solve(self, config: Config, timer: HierarchicalTimer) -> None: + def _solve(self, config: KnitroConfig, timer: HierarchicalTimer) -> None: self._engine.renew() timer.start("add_vars") diff --git a/pyomo/contrib/solver/solvers/knitro/engine.py b/pyomo/contrib/solver/solvers/knitro/engine.py index 06885430662..67b416b7481 100644 --- a/pyomo/contrib/solver/solvers/knitro/engine.py +++ b/pyomo/contrib/solver/solvers/knitro/engine.py @@ -1,33 +1,47 @@ +# ___________________________________________________________________________ +# +# 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, Mapping, MutableMapping, Sequence from types import MappingProxyType from typing import Any, Optional, Protocol, TypeVar from pyomo.common.enums import ObjectiveSense from pyomo.common.numeric_types import value -from pyomo.core.base.constraint import ConstraintData -from pyomo.core.base.objective import ObjectiveData -from pyomo.core.base.var import VarData -from pyomo.repn.standard_repn import generate_standard_repn - -from .api import knitro -from .package import Package -from .typing import ( - Atom, +from pyomo.contrib.solver.solvers.knitro.api import knitro +from pyomo.contrib.solver.solvers.knitro.package import Package +from pyomo.contrib.solver.solvers.knitro.typing import ( BoundType, Callback, - ItemType, - T, + Function, + ItemData, + CallbackFunction, Request, Result, StructureType, + ItemType, UnreachableError, ValueType, ) -from .utils import NonlinearExpressionData +from pyomo.contrib.solver.solvers.knitro.utils import NonlinearExpressionData +from pyomo.core.base.constraint import ConstraintData +from pyomo.core.base.objective import ObjectiveData +from pyomo.core.base.var import VarData +from pyomo.repn.standard_repn import generate_standard_repn + + +_CallbackFunction = Callable[[Request, Result], int] class _Callback(Protocol): - _atom: Atom + _function: Function def func(self, req: Request, res: Result) -> int: ... def grad(self, req: Request, res: Result) -> int: ... @@ -38,7 +52,7 @@ def expand(self) -> Callback: return Callback(*map(self._expand, procs)) @staticmethod - def _expand(proc: Callable[[Request, Result], int]): + def _expand(proc: _CallbackFunction) -> CallbackFunction: def _expanded( kc: Any, cb: Any, req: Request, res: Result, user_data: Any = None ) -> int: @@ -48,44 +62,44 @@ def _expanded( class _ObjectiveCallback(_Callback): - def __init__(self, atom: Atom) -> None: - self._atom = atom + def __init__(self, function: Function) -> None: + self._function = function def func(self, req: Request, res: Result) -> int: - res.obj = self._atom.func(req.x) + res.obj = self._function.evaluate(req.x) return 0 def grad(self, req: Request, res: Result) -> int: - res.objGrad[:] = self._atom.grad(req.x) + res.objGrad[:] = self._function.gradient(req.x) return 0 def hess(self, req: Request, res: Result) -> int: - res.hess[:] = self._atom.hess(req.x, req.sigma) + res.hess[:] = self._function.hessian(req.x, req.sigma) return 0 class _ConstraintCallback(_Callback): i: int - def __init__(self, i: int, atom: Atom) -> None: + def __init__(self, i: int, evaluator: Function) -> None: self.i = i - self._atom = atom + self._function = evaluator def func(self, req: Request, res: Result) -> int: - res.c[:] = [self._atom.func(req.x)] + res.c[:] = [self._function.evaluate(req.x)] return 0 def grad(self, req: Request, res: Result) -> int: - res.jac[:] = self._atom.grad(req.x) + res.jac[:] = self._function.gradient(req.x) return 0 def hess(self, req: Request, res: Result) -> int: - res.hess[:] = self._atom.hess(req.x, req.lambda_[self.i]) + res.hess[:] = self._function.hessian(req.x, req.lambda_[self.i]) return 0 def parse_bounds( - items: Iterable[T], idx_map: Mapping[int, int] + items: Iterable[ItemType], idx_map: Mapping[int, int] ) -> Mapping[BoundType, MutableMapping[int, float]]: bounds_map = {bnd_type: {} for bnd_type in BoundType} for item in items: @@ -109,7 +123,9 @@ def parse_bounds( return bounds_map -def parse_types(items: Iterable[T], idx_map: Mapping[int, int]) -> Mapping[int, int]: +def parse_types( + items: Iterable[ItemType], idx_map: Mapping[int, int] +) -> Mapping[int, int]: types_map = {} for item in items: i = idx_map[id(item)] @@ -137,7 +153,7 @@ def api_set_param(param_type: int) -> Callable[..., None]: def api_get_values( - item_type: type[T], value_type: ValueType + item_type: type[ItemType], value_type: ValueType ) -> Callable[..., Optional[list[float]]]: if item_type is VarData: if value_type == ValueType.PRIMAL: @@ -152,7 +168,7 @@ def api_get_values( raise UnreachableError() -def api_add_items(item_type: type[T]) -> Callable[..., Optional[list[int]]]: +def api_add_items(item_type: type[ItemType]) -> Callable[..., Optional[list[int]]]: if item_type is VarData: return knitro.KN_add_vars elif item_type is ConstraintData: @@ -160,7 +176,9 @@ def api_add_items(item_type: type[T]) -> Callable[..., Optional[list[int]]]: raise UnreachableError() -def api_set_bnds(item_type: type[T], bound_type: BoundType) -> Callable[..., None]: +def api_set_bnds( + item_type: type[ItemType], bound_type: BoundType +) -> Callable[..., None]: if item_type is VarData: if bound_type == BoundType.EQ: return knitro.KN_set_var_fxbnds @@ -178,7 +196,7 @@ def api_set_bnds(item_type: type[T], bound_type: BoundType) -> Callable[..., Non raise UnreachableError() -def api_set_types(item_type: type[T]) -> Callable[..., None]: +def api_set_types(item_type: type[ItemType]) -> Callable[..., None]: if item_type is VarData: return knitro.KN_set_var_types raise UnreachableError() @@ -205,7 +223,7 @@ class Engine: """A wrapper around the KNITRO API for a single optimization problem.""" has_objective: bool - maps: Mapping[type[ItemType], MutableMapping[int, int]] + maps: Mapping[type[ItemData], MutableMapping[int, int]] nonlinear_map: MutableMapping[Optional[int], NonlinearExpressionData] nonlinear_diff_order: int @@ -245,9 +263,9 @@ def close(self) -> None: self.execute(knitro.KN_free) self._kc = None - R = TypeVar("R") + T = TypeVar("T") - def execute(self, api_func: Callable[..., R], *args, **kwargs) -> R: + def execute(self, api_func: Callable[..., T], *args, **kwargs) -> T: if self._kc is None: msg = "KNITRO context has not been initialized or has been freed." raise RuntimeError(msg) @@ -320,12 +338,17 @@ def get_obj_value(self) -> Optional[float]: return None return self.execute(knitro.KN_get_obj_value) - def get_idxs(self, item_type: type[T], items: Iterable[T]) -> list[int]: + def get_idxs( + self, item_type: type[ItemType], items: Iterable[ItemType] + ) -> list[int]: idx_map = self.maps[item_type] return [idx_map[id(item)] for item in items] def get_values( - self, item_type: type[T], value_type: ValueType, items: Iterable[T] + self, + item_type: type[ItemType], + value_type: ValueType, + items: Iterable[ItemType], ) -> Optional[list[float]]: func = api_get_values(item_type, value_type) idxs = self.get_idxs(item_type, items) @@ -345,13 +368,13 @@ def set_obj_goal(self, sense: ObjectiveSense) -> None: ) self.execute(knitro.KN_set_obj_goal, obj_goal) - def add_items(self, item_type: type[T], items: Sequence[T]) -> None: + def add_items(self, item_type: type[ItemType], items: Sequence[ItemType]) -> None: func = api_add_items(item_type) idxs = self.execute(func, len(items)) if idxs is not None: self.maps[item_type].update(zip(map(id, items), idxs)) - def set_bounds(self, item_type: type[T], items: Iterable[T]) -> None: + def set_bounds(self, item_type: type[ItemType], items: Iterable[ItemType]) -> None: bounds_map = parse_bounds(items, self.maps[item_type]) for bound_type, bounds in bounds_map.items(): if not bounds: @@ -360,7 +383,7 @@ def set_bounds(self, item_type: type[T], items: Iterable[T]) -> None: func = api_set_bnds(item_type, bound_type) self.execute(func, bounds.keys(), bounds.values()) - def set_types(self, item_type: type[T], items: Iterable[T]) -> None: + def set_types(self, item_type: type[ItemType], items: Iterable[ItemType]) -> None: types_map = parse_types(items, self.maps[item_type]) if types_map: func = api_set_types(item_type) diff --git a/pyomo/contrib/solver/solvers/knitro/package.py b/pyomo/contrib/solver/solvers/knitro/package.py index dae1bf573eb..3c52d833faf 100644 --- a/pyomo/contrib/solver/solvers/knitro/package.py +++ b/pyomo/contrib/solver/solvers/knitro/package.py @@ -14,13 +14,15 @@ from pyomo.common.tee import TeeStream, capture_output from pyomo.contrib.solver.common.base import Availability - -from .api import KNITRO_AVAILABLE, KNITRO_VERSION, knitro +from pyomo.contrib.solver.solvers.knitro.api import ( + KNITRO_AVAILABLE, + KNITRO_VERSION, + knitro, +) class Package: - """ - Manages the global KNITRO license context and provides utility methods for license handling. + """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. @@ -30,11 +32,11 @@ class Package: @staticmethod def initialize_license(): - """ - Initialize the global KNITRO license context if not already initialized. + """Initialize the global KNITRO license context if not already initialized. Returns: The KNITRO license context object. + """ if Package._license_context is None: Package._license_context = knitro.KN_checkout_license() @@ -42,42 +44,40 @@ def initialize_license(): @staticmethod def release_license() -> None: - """ - Release the global KNITRO license context if it exists. - """ + """Release the global KNITRO license context if it exists.""" if Package._license_context is not None: knitro.KN_release_license(Package._license_context) Package._license_context = None @staticmethod def create_context(): - """ - Create a new KNITRO context using the global license context. + """Create a new KNITRO context using the global license context. Returns: The new KNITRO context object. + """ lmc = Package.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. + """Get the version of the KNITRO solver as a tuple. Returns: tuple[int, int, int]: The (major, minor, patch) version of KNITRO. + """ major, minor, patch = map(int, KNITRO_VERSION.split(".")) return major, minor, patch @staticmethod def check_availability() -> Availability: - """ - Check if the KNITRO solver and license are available. + """Check if the KNITRO solver and license are available. Returns: Availability: The availability status (FullLicense, BadLicense, NotFound). + """ if not bool(KNITRO_AVAILABLE): return Availability.NotFound diff --git a/pyomo/contrib/solver/solvers/knitro/solution.py b/pyomo/contrib/solver/solvers/knitro/solution.py index 9a4577b4187..1222446a7a5 100644 --- a/pyomo/contrib/solver/solvers/knitro/solution.py +++ b/pyomo/contrib/solver/solvers/knitro/solution.py @@ -13,24 +13,23 @@ from typing import Optional, Protocol from pyomo.contrib.solver.common.solution_loader import SolutionLoaderBase +from pyomo.contrib.solver.solvers.knitro.typing import ItemType, ValueType from pyomo.core.base.constraint import ConstraintData from pyomo.core.base.var import VarData -from .typing import T, ValueType - class SolutionProvider(Protocol): def get_num_solutions(self) -> int: ... def get_values( self, - item_type: type[T], + item_type: type[ItemType], value_type: ValueType, - items: Optional[Sequence[T]] = None, + items: Optional[Sequence[ItemType]] = None, *, exists: bool, solution_id: Optional[int] = None, - ) -> Mapping[T, float]: ... + ) -> Mapping[ItemType, float]: ... class SolutionLoader(SolutionLoaderBase): diff --git a/pyomo/contrib/solver/solvers/knitro/typing.py b/pyomo/contrib/solver/solvers/knitro/typing.py index 2bd6bc3967c..731d43cfb52 100644 --- a/pyomo/contrib/solver/solvers/knitro/typing.py +++ b/pyomo/contrib/solver/solvers/knitro/typing.py @@ -38,14 +38,14 @@ def sign(self) -> float: return -1.0 if self == ValueType.DUAL else 1.0 -ItemType = Union[VarData, ConstraintData] -T = TypeVar("T", bound=ItemType) +ItemData = Union[VarData, ConstraintData] +ItemType = TypeVar("ItemType", bound=ItemData) -class Atom(Protocol): - def func(self, x: list[float]) -> float: ... - def grad(self, x: list[float]) -> list[float]: ... - def hess(self, x: list[float], mu: float) -> list[float]: ... +class Function(Protocol): + def evaluate(self, x: list[float]) -> float: ... + def gradient(self, x: list[float]) -> list[float]: ... + def hessian(self, x: list[float], mu: float) -> list[float]: ... class Request(Protocol): @@ -62,12 +62,12 @@ class Result(Protocol): hess: list[float] +CallbackFunction = Callable[[Any, Any, Request, Result, Optional[Any]], int] + + class Callback: def __init__( - self, - func: Callable[[Any, Any, Request, Result, Optional[Any]], int], - grad: Callable[[Any, Any, Request, Result, Optional[Any]], int], - hess: Callable[[Any, Any, Request, Result, Optional[Any]], int], + self, func: CallbackFunction, grad: CallbackFunction, hess: CallbackFunction ) -> None: self.func = func self.grad = grad diff --git a/pyomo/contrib/solver/solvers/knitro/utils.py b/pyomo/contrib/solver/solvers/knitro/utils.py index d471225bedc..91cf4670072 100644 --- a/pyomo/contrib/solver/solvers/knitro/utils.py +++ b/pyomo/contrib/solver/solvers/knitro/utils.py @@ -22,18 +22,18 @@ from pyomo.core.base.var import VarData from pyomo.core.expr.calculus.diff_with_pyomo import reverse_sd -from .typing import Atom +from pyomo.contrib.solver.solvers.knitro.typing import Function def get_active_objectives(block: BlockData) -> list[ObjectiveData]: - """ - Retrieve all active ObjectiveData objects from a Pyomo Block. + """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 @@ -42,14 +42,14 @@ def get_active_objectives(block: BlockData) -> list[ObjectiveData]: def get_active_constraints(block: BlockData) -> list[ConstraintData]: - """ - Retrieve all active ConstraintData objects from a Pyomo Block. + """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 @@ -57,9 +57,8 @@ def get_active_constraints(block: BlockData) -> list[ConstraintData]: return list(generator) -class Problem: - """ - Intermediate representation of a Pyomo model for KNITRO. +class ModelCollector: + """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. @@ -68,6 +67,7 @@ class Problem: 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] @@ -76,12 +76,12 @@ class Problem: _vars: MutableSet[VarData] def __init__(self, block: Optional[BlockData] = None) -> None: - """ - Initialize a Problem instance. + """Initialize a Problem instance. Args: block (Optional[BlockData]): Pyomo block to initialize from. If None, creates an empty problem that can be populated later. + """ self._vars = ComponentSet() self.objs = [] @@ -98,21 +98,21 @@ def clear(self) -> None: self._vars.clear() def set_block(self, block: BlockData) -> None: - """ - Replace the current problem data with data from a new block. + """Replace the current problem data with data from a new block. Args: block (BlockData): The Pyomo block to extract data from. + """ self.clear() self.add_block(block) def add_block(self, block: BlockData) -> None: - """ - Add objectives, constraints, and variables from a block to the problem. + """Add objectives, constraints, and variables from a block to the problem. Args: block (BlockData): The Pyomo block to extract data from. + """ new_objs = get_active_objectives(block) new_cons = get_active_constraints(block) @@ -138,23 +138,22 @@ def add_block(self, block: BlockData) -> None: def set_var_values( variables: Iterable[VarData], values: Sequence[float], var_map: Mapping[int, int] ) -> None: - """ - Set the values of a list of Pyomo variables from a list of values. + """Set the values of a list of Pyomo variables from a list of values. Args: variables (Iterable[VarData]): The variables to set. values (list[float]): The list of values to assign to the variables. var_map (Mapping[int, int]): A mapping from variable id to index in the values list. + """ for var in variables: i = var_map[id(var)] var.set_value(values[i]) -class NonlinearExpressionData(Atom): - """ - Holds the data required to evaluate a non-linear expression. +class NonlinearExpressionData(Function): + """Holds the data required to evaluate a non-linear expression. This class stores a Pyomo expression along with its variables and can compute gradient and Hessian information for use with optimization solvers. @@ -169,6 +168,7 @@ class NonlinearExpressionData(Atom): - 0: function evaluation only - 1: function + gradient - 2: function + gradient + hessian + """ variables: list[VarData] @@ -184,8 +184,7 @@ def __init__( var_map: Mapping[int, int], diff_order: int = 0, ) -> None: - """ - Initialize NonlinearExpressionData. + """Initialize NonlinearExpressionData. Args: expr (Expression): The Pyomo expression to evaluate. @@ -194,6 +193,7 @@ def __init__( - 0: function evaluation only - 1: function + gradient - 2: function + gradient + hessian + """ self.func_expr = expr self.variables = list(variables) @@ -206,18 +206,17 @@ def __init__( @property def grad_vars(self) -> list[VarData]: - """ - Get the list of variables for which gradients are available. + """Get the list of variables for which gradients are available. Returns: list[VarData]: Variables with gradient information. + """ return list(self.grad_map.keys()) @property def hess_vars(self) -> list[tuple[VarData, VarData]]: - """ - Get the list of variable pairs for which Hessian entries are available. + """Get the list of variable pairs for which Hessian entries are available. Returns: list[tuple[VarData, VarData]]: Variable pairs with Hessian information. @@ -226,8 +225,7 @@ def hess_vars(self) -> list[tuple[VarData, VarData]]: return list(self.hess_map.keys()) def compute_gradient(self) -> None: - """ - Compute gradient expressions for the nonlinear expression. + """Compute gradient expressions for the nonlinear expression. This method computes the gradient of the expression with respect to all variables and stores the results in the grad attribute. @@ -240,8 +238,7 @@ def compute_gradient(self) -> None: self.grad_map[v] = expr def compute_hessian(self) -> None: - """ - Compute Hessian expressions for the nonlinear expression. + """Compute Hessian expressions for the nonlinear expression. This method computes the Hessian matrix of the expression with respect to all variables and stores the results in the hess attribute. Only the upper triangle @@ -249,6 +246,7 @@ def compute_hessian(self) -> None: Note: This method requires that compute_gradient() has been called first. + """ variables = ComponentSet(self.variables) self.hess_map = ComponentMap() @@ -262,14 +260,14 @@ def compute_hessian(self) -> None: if (var1, var2) not in self.hess_map: self.hess_map[(var1, var2)] = hess_expr - def func(self, x: list[float]) -> float: + def evaluate(self, x: list[float]) -> float: set_var_values(self.variables, x, self._var_map) return value(self.func_expr) - def grad(self, x: list[float]) -> list[float]: + def gradient(self, x: list[float]) -> list[float]: set_var_values(self.variables, x, self._var_map) return [value(g) for g in self.grad_map.values()] - def hess(self, x: list[float], mu: float) -> list[float]: + def hessian(self, x: list[float], mu: float) -> list[float]: set_var_values(self.variables, x, self._var_map) return [mu * value(h) for h in self.hess_map.values()] diff --git a/pyomo/contrib/solver/tests/solvers/test_knitro_direct.py b/pyomo/contrib/solver/tests/solvers/test_knitro_direct.py index 2eeca7e042a..1334faaba68 100644 --- a/pyomo/contrib/solver/tests/solvers/test_knitro_direct.py +++ b/pyomo/contrib/solver/tests/solvers/test_knitro_direct.py @@ -9,7 +9,7 @@ # This software is distributed under the 3-clause BSD License. # ___________________________________________________________________________ -import unittest +import pyomo.common.unittest as unittest import pyomo.contrib.solver.solvers.knitro as knitro import pyomo.environ as pyo From df9e1ea793e109dba79b962a37cd418ae2e38a15 Mon Sep 17 00:00:00 2001 From: eminyouskn Date: Thu, 25 Sep 2025 10:56:28 -0400 Subject: [PATCH 50/64] Refactor --- pyomo/contrib/solver/solvers/knitro/base.py | 21 ++-- .../contrib/solver/solvers/knitro/callback.py | 92 ++++++++++++++++ pyomo/contrib/solver/solvers/knitro/direct.py | 10 +- pyomo/contrib/solver/solvers/knitro/engine.py | 100 ++++-------------- pyomo/contrib/solver/solvers/knitro/typing.py | 19 ++-- pyomo/contrib/solver/solvers/knitro/utils.py | 9 +- 6 files changed, 139 insertions(+), 112 deletions(-) create mode 100644 pyomo/contrib/solver/solvers/knitro/callback.py diff --git a/pyomo/contrib/solver/solvers/knitro/base.py b/pyomo/contrib/solver/solvers/knitro/base.py index 684b1f5266e..a48f77a67b2 100644 --- a/pyomo/contrib/solver/solvers/knitro/base.py +++ b/pyomo/contrib/solver/solvers/knitro/base.py @@ -20,7 +20,7 @@ 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 +from pyomo.contrib.solver.common.base import SolverBase from pyomo.contrib.solver.common.results import ( Results, SolutionStatus, @@ -47,27 +47,27 @@ UnreachableError, ValueType, ) -from pyomo.contrib.solver.solvers.knitro.utils import ModelCollector +from pyomo.contrib.solver.solvers.knitro.utils import KnitroModelData 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 -class KnitroSolverBase(SolutionProvider, PackageChecker, base.SolverBase): +class KnitroSolverBase(SolutionProvider, PackageChecker, SolverBase): CONFIG = KnitroConfig() config: KnitroConfig _engine: Engine - _problem: ModelCollector + _model_data: KnitroModelData _stream: StringIO _saved_var_values: dict[int, Optional[float]] def __init__(self, **kwds) -> None: PackageChecker.__init__(self) - base.SolverBase.__init__(self, **kwds) + SolverBase.__init__(self, **kwds) self._engine = Engine() - self._problem = ModelCollector() + self._model_data = KnitroModelData() self._stream = StringIO() self._saved_var_values = {} @@ -105,7 +105,7 @@ def _build_config(self, **kwds) -> KnitroConfig: return self.config(value=kwds, preserve_implicit=True) # type: ignore def _validate_problem(self) -> None: - if len(self._problem.objs) > 1: + if len(self._model_data.objs) > 1: msg = f"{self.name} does not support multiple objectives." raise IncompatibleModelError(msg) @@ -198,10 +198,13 @@ def get_num_solutions(self) -> int: return self._engine.get_num_solutions() def _get_vars(self) -> list[VarData]: - return self._problem.variables + return self._model_data.variables def _get_items(self, item_type: type[ItemType]) -> Sequence[ItemType]: - maps = {VarData: self._problem.variables, ConstraintData: self._problem.cons} + maps = { + VarData: self._model_data.variables, + ConstraintData: self._model_data.cons, + } return maps[item_type] @staticmethod diff --git a/pyomo/contrib/solver/solvers/knitro/callback.py b/pyomo/contrib/solver/solvers/knitro/callback.py new file mode 100644 index 00000000000..09192b988d1 --- /dev/null +++ b/pyomo/contrib/solver/solvers/knitro/callback.py @@ -0,0 +1,92 @@ +# ___________________________________________________________________________ +# +# 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 +from typing import Any, Optional, Protocol + +from pyomo.contrib.solver.solvers.knitro.typing import ( + Callback, + CallbackFunction, + CallbackRequest, + CallbackResult, + Function, +) + +CallbackHandlerFunction = Callable[[CallbackRequest, CallbackResult], int] + + +class CallbackHandler(Protocol): + _function: Function + + def func(self, req: CallbackRequest, res: CallbackResult) -> int: ... + def grad(self, req: CallbackRequest, res: CallbackResult) -> int: ... + def hess(self, req: CallbackRequest, res: CallbackResult) -> int: ... + + def expand(self) -> Callback: + procs = (self.func, self.grad, self.hess) + return Callback(*map(self._expand, procs)) + + @staticmethod + def _expand(proc: CallbackHandlerFunction) -> CallbackFunction: + def _expanded( + kc: Any, + cb: Any, + req: CallbackRequest, + res: CallbackResult, + user_data: Any = None, + ) -> int: + return proc(req, res) + + return _expanded + + +class ObjectiveCallbackHandler(CallbackHandler): + def __init__(self, function: Function) -> None: + self._function = function + + def func(self, req: CallbackRequest, res: CallbackResult) -> int: + res.obj = self._function.evaluate(req.x) + return 0 + + def grad(self, req: CallbackRequest, res: CallbackResult) -> int: + res.objGrad[:] = self._function.gradient(req.x) + return 0 + + def hess(self, req: CallbackRequest, res: CallbackResult) -> int: + res.hess[:] = self._function.hessian(req.x, req.sigma) + return 0 + + +class ConstraintCallbackHandler(CallbackHandler): + i: int + + def __init__(self, i: int, function: Function) -> None: + self.i = i + self._function = function + + def func(self, req: CallbackRequest, res: CallbackResult) -> int: + res.c[:] = [self._function.evaluate(req.x)] + return 0 + + def grad(self, req: CallbackRequest, res: CallbackResult) -> int: + res.jac[:] = self._function.gradient(req.x) + return 0 + + def hess(self, req: CallbackRequest, res: CallbackResult) -> int: + res.hess[:] = self._function.hessian(req.x, req.lambda_[self.i]) + return 0 + + +def build_callback_handler(function, *, idx: Optional[int] = None) -> CallbackHandler: + is_obj = idx is None + if is_obj: + return ObjectiveCallbackHandler(function) + return ConstraintCallbackHandler(idx, function) diff --git a/pyomo/contrib/solver/solvers/knitro/direct.py b/pyomo/contrib/solver/solvers/knitro/direct.py index 579eea7c913..8bab37032e3 100644 --- a/pyomo/contrib/solver/solvers/knitro/direct.py +++ b/pyomo/contrib/solver/solvers/knitro/direct.py @@ -21,23 +21,23 @@ def _presolve( self, model: BlockData, config: KnitroConfig, timer: HierarchicalTimer ) -> None: timer.start("build_problem") - self._problem.set_block(model) + self._model_data.set_block(model) timer.stop("build_problem") def _solve(self, config: KnitroConfig, timer: HierarchicalTimer) -> None: self._engine.renew() timer.start("add_vars") - self._engine.add_vars(self._problem.variables) + self._engine.add_vars(self._model_data.variables) timer.stop("add_vars") timer.start("add_cons") - self._engine.add_cons(self._problem.cons) + self._engine.add_cons(self._model_data.cons) timer.stop("add_cons") - if self._problem.objs: + if self._model_data.objs: timer.start("set_objective") - self._engine.set_obj(self._problem.objs[0]) + self._engine.set_obj(self._model_data.objs[0]) timer.stop("set_objective") self._engine.set_outlev() diff --git a/pyomo/contrib/solver/solvers/knitro/engine.py b/pyomo/contrib/solver/solvers/knitro/engine.py index 67b416b7481..c6c3d115113 100644 --- a/pyomo/contrib/solver/solvers/knitro/engine.py +++ b/pyomo/contrib/solver/solvers/knitro/engine.py @@ -11,22 +11,19 @@ from collections.abc import Callable, Iterable, Mapping, MutableMapping, Sequence from types import MappingProxyType -from typing import Any, Optional, Protocol, TypeVar +from typing import Any, Optional, TypeVar from pyomo.common.enums import ObjectiveSense from pyomo.common.numeric_types import value from pyomo.contrib.solver.solvers.knitro.api import knitro +from pyomo.contrib.solver.solvers.knitro.callback import build_callback_handler from pyomo.contrib.solver.solvers.knitro.package import Package from pyomo.contrib.solver.solvers.knitro.typing import ( BoundType, Callback, - Function, ItemData, - CallbackFunction, - Request, - Result, - StructureType, ItemType, + StructureType, UnreachableError, ValueType, ) @@ -37,67 +34,6 @@ from pyomo.repn.standard_repn import generate_standard_repn -_CallbackFunction = Callable[[Request, Result], int] - - -class _Callback(Protocol): - _function: Function - - def func(self, req: Request, res: Result) -> int: ... - def grad(self, req: Request, res: Result) -> int: ... - def hess(self, req: Request, res: Result) -> int: ... - - def expand(self) -> Callback: - procs = (self.func, self.grad, self.hess) - return Callback(*map(self._expand, procs)) - - @staticmethod - def _expand(proc: _CallbackFunction) -> CallbackFunction: - def _expanded( - kc: Any, cb: Any, req: Request, res: Result, user_data: Any = None - ) -> int: - return proc(req, res) - - return _expanded - - -class _ObjectiveCallback(_Callback): - def __init__(self, function: Function) -> None: - self._function = function - - def func(self, req: Request, res: Result) -> int: - res.obj = self._function.evaluate(req.x) - return 0 - - def grad(self, req: Request, res: Result) -> int: - res.objGrad[:] = self._function.gradient(req.x) - return 0 - - def hess(self, req: Request, res: Result) -> int: - res.hess[:] = self._function.hessian(req.x, req.sigma) - return 0 - - -class _ConstraintCallback(_Callback): - i: int - - def __init__(self, i: int, evaluator: Function) -> None: - self.i = i - self._function = evaluator - - def func(self, req: Request, res: Result) -> int: - res.c[:] = [self._function.evaluate(req.x)] - return 0 - - def grad(self, req: Request, res: Result) -> int: - res.jac[:] = self._function.gradient(req.x) - return 0 - - def hess(self, req: Request, res: Result) -> int: - res.hess[:] = self._function.hessian(req.x, req.lambda_[self.i]) - return 0 - - def parse_bounds( items: Iterable[ItemType], idx_map: Mapping[int, int] ) -> Mapping[BoundType, MutableMapping[int, float]]: @@ -311,7 +247,8 @@ def get_idx_vars(self, variables: Iterable[VarData]) -> list[int]: def get_status(self) -> int: if self._status is None: - raise RuntimeError("Solver has not been run, so no status is available.") + msg = "Solver has not been run. No status is available!" + raise RuntimeError(msg) return self._status def get_num_iters(self) -> int: @@ -404,7 +341,8 @@ def add_structures(self, i: Optional[int], expr) -> None: is_obj = i is None base_args = () if is_obj else (i,) - structure_type_seq, args_seq = [], [] + structure_type_seq: list[StructureType] = [] + args_seq: list[tuple[Any, ...]] = [] if repn.constant is not None: structure_type_seq += [StructureType.CONSTANT] @@ -436,20 +374,8 @@ def add_structures(self, i: Optional[int], expr) -> None: diff_order=self.nonlinear_diff_order, ) - def register_callbacks(self) -> None: - for i, expr in self.nonlinear_map.items(): - self.register_callback(i, expr) - - def register_callback( - self, i: Optional[int], expr: NonlinearExpressionData - ) -> None: + def add_callback(self, i: Optional[int], expr: NonlinearExpressionData, callback: Callback) -> None: is_obj = i is None - if is_obj: - ne = _ObjectiveCallback(expr) - else: - ne = _ConstraintCallback(i, expr) - callback = ne.expand() - idx_cons = [i] if not is_obj else None cb = self.execute(knitro.KN_add_eval_callback, is_obj, idx_cons, callback.func) @@ -474,3 +400,13 @@ def register_callback( self.execute( knitro.KN_set_cb_hess, cb, hess_idx_vars1, hess_idx_vars2, callback.hess ) + + def register_callbacks(self) -> None: + for i, expr in self.nonlinear_map.items(): + self.register_callback(i, expr) + + def register_callback( + self, i: Optional[int], expr: NonlinearExpressionData + ) -> None: + callback = build_callback_handler(expr, idx=i).expand() + self.add_callback(i, callback) diff --git a/pyomo/contrib/solver/solvers/knitro/typing.py b/pyomo/contrib/solver/solvers/knitro/typing.py index 731d43cfb52..c44eb19e733 100644 --- a/pyomo/contrib/solver/solvers/knitro/typing.py +++ b/pyomo/contrib/solver/solvers/knitro/typing.py @@ -10,7 +10,7 @@ # ___________________________________________________________________________ from collections.abc import Callable -from typing import Any, Optional, Protocol, TypeVar, Union +from typing import Any, NamedTuple, Protocol, TypeVar, Union from pyomo.common.enums import Enum from pyomo.core.base.constraint import ConstraintData @@ -48,13 +48,13 @@ def gradient(self, x: list[float]) -> list[float]: ... def hessian(self, x: list[float], mu: float) -> list[float]: ... -class Request(Protocol): +class CallbackRequest(Protocol): x: list[float] sigma: float lambda_: list[float] -class Result(Protocol): +class CallbackResult(Protocol): obj: float c: list[float] objGrad: list[float] @@ -62,16 +62,13 @@ class Result(Protocol): hess: list[float] -CallbackFunction = Callable[[Any, Any, Request, Result, Optional[Any]], int] +CallbackFunction = Callable[[Any, Any, CallbackRequest, CallbackResult, Any], int] -class Callback: - def __init__( - self, func: CallbackFunction, grad: CallbackFunction, hess: CallbackFunction - ) -> None: - self.func = func - self.grad = grad - self.hess = hess +class Callback(NamedTuple): + func: CallbackFunction + grad: CallbackFunction + hess: CallbackFunction class UnreachableError(Exception): diff --git a/pyomo/contrib/solver/solvers/knitro/utils.py b/pyomo/contrib/solver/solvers/knitro/utils.py index 91cf4670072..4b570d6121f 100644 --- a/pyomo/contrib/solver/solvers/knitro/utils.py +++ b/pyomo/contrib/solver/solvers/knitro/utils.py @@ -15,6 +15,7 @@ from pyomo.common.collections import ComponentMap, ComponentSet from pyomo.common.numeric_types import value from pyomo.contrib.solver.common.util import collect_vars_and_named_exprs +from pyomo.contrib.solver.solvers.knitro.typing import Function from pyomo.core.base.block import BlockData from pyomo.core.base.constraint import Constraint, ConstraintData from pyomo.core.base.expression import Expression @@ -22,8 +23,6 @@ from pyomo.core.base.var import VarData from pyomo.core.expr.calculus.diff_with_pyomo import reverse_sd -from pyomo.contrib.solver.solvers.knitro.typing import Function - def get_active_objectives(block: BlockData) -> list[ObjectiveData]: """Retrieve all active ObjectiveData objects from a Pyomo Block. @@ -57,7 +56,7 @@ def get_active_constraints(block: BlockData) -> list[ConstraintData]: return list(generator) -class ModelCollector: +class KnitroModelData: """Intermediate representation of a Pyomo model for KNITRO. Collects all active objectives, constraints, and referenced variables from a Pyomo Block. @@ -121,13 +120,13 @@ def add_block(self, block: BlockData) -> None: # Collect variables from objectives for obj in new_objs: - _, variables, _, _ = collect_vars_and_named_exprs(obj.expr) + _, variables, _, _ = collect_vars_and_named_exprs(obj.expr) # type: ignore for var in variables: self._vars.add(var) # Collect variables from constraints for con in new_cons: - _, variables, _, _ = collect_vars_and_named_exprs(con.body) + _, variables, _, _ = collect_vars_and_named_exprs(con.body) # type: ignore for var in variables: self._vars.add(var) From 31634cdd5962cad8e202c78b7d02e900fd2079ec Mon Sep 17 00:00:00 2001 From: eminyouskn Date: Thu, 25 Sep 2025 10:56:51 -0400 Subject: [PATCH 51/64] Black format --- pyomo/contrib/solver/solvers/knitro/engine.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/pyomo/contrib/solver/solvers/knitro/engine.py b/pyomo/contrib/solver/solvers/knitro/engine.py index c6c3d115113..5baecf6a305 100644 --- a/pyomo/contrib/solver/solvers/knitro/engine.py +++ b/pyomo/contrib/solver/solvers/knitro/engine.py @@ -374,7 +374,9 @@ def add_structures(self, i: Optional[int], expr) -> None: diff_order=self.nonlinear_diff_order, ) - def add_callback(self, i: Optional[int], expr: NonlinearExpressionData, callback: Callback) -> None: + def add_callback( + self, i: Optional[int], expr: NonlinearExpressionData, callback: Callback + ) -> None: is_obj = i is None idx_cons = [i] if not is_obj else None cb = self.execute(knitro.KN_add_eval_callback, is_obj, idx_cons, callback.func) From c88b0f7a0e208722542a8b7e81cd0899e028cf8e Mon Sep 17 00:00:00 2001 From: eminyouskn Date: Thu, 25 Sep 2025 11:00:30 -0400 Subject: [PATCH 52/64] Fix add_callback call. --- pyomo/contrib/solver/solvers/knitro/engine.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyomo/contrib/solver/solvers/knitro/engine.py b/pyomo/contrib/solver/solvers/knitro/engine.py index 5baecf6a305..561c3ad734f 100644 --- a/pyomo/contrib/solver/solvers/knitro/engine.py +++ b/pyomo/contrib/solver/solvers/knitro/engine.py @@ -411,4 +411,4 @@ def register_callback( self, i: Optional[int], expr: NonlinearExpressionData ) -> None: callback = build_callback_handler(expr, idx=i).expand() - self.add_callback(i, callback) + self.add_callback(i, expr, callback) From 0ee97e88f43f0b43db720512b4cd4bdca29a9368 Mon Sep 17 00:00:00 2001 From: eminyouskn Date: Thu, 25 Sep 2025 11:19:00 -0400 Subject: [PATCH 53/64] Fix and sort imports --- .../solver/tests/solvers/test_solvers.py | 28 +++++++------------ 1 file changed, 10 insertions(+), 18 deletions(-) diff --git a/pyomo/contrib/solver/tests/solvers/test_solvers.py b/pyomo/contrib/solver/tests/solvers/test_solvers.py index e70de545f8e..86523de001d 100644 --- a/pyomo/contrib/solver/tests/solvers/test_solvers.py +++ b/pyomo/contrib/solver/tests/solvers/test_solvers.py @@ -10,43 +10,35 @@ # ___________________________________________________________________________ import datetime -import random import math +import random from typing import Type +import pyomo.common.unittest as unittest import pyomo.environ as pyo from pyomo import gdp from pyomo.common.dependencies import attempt_import -import pyomo.common.unittest as unittest - from pyomo.contrib.solver.common.base import SolverBase from pyomo.contrib.solver.common.config import SolverConfig from pyomo.contrib.solver.common.factory import SolverFactory -from pyomo.contrib.solver.solvers.gurobi_persistent import GurobiPersistent -from pyomo.contrib.solver.solvers.gurobi_direct import GurobiDirect -from pyomo.contrib.solver.solvers.highs import Highs -from pyomo.contrib.solver.solvers.ipopt import Ipopt from pyomo.contrib.solver.common.results import ( - TerminationCondition, - SolutionStatus, Results, + SolutionStatus, + TerminationCondition, ) from pyomo.contrib.solver.common.util import ( NoDualsError, - NoSolutionError, NoReducedCostsError, + NoSolutionError, ) -from pyomo.contrib.solver.common.base import SolverBase -from pyomo.contrib.solver.common.factory import SolverFactory -from pyomo.contrib.solver.solvers.knitro import KnitroDirectSolver -from pyomo.contrib.solver.solvers.ipopt import Ipopt -from pyomo.contrib.solver.solvers.gurobi_persistent import GurobiPersistent from pyomo.contrib.solver.solvers.gurobi_direct import GurobiDirect +from pyomo.contrib.solver.solvers.gurobi_persistent import GurobiPersistent from pyomo.contrib.solver.solvers.highs import Highs -from pyomo.core.expr.numeric_expr import LinearExpression -from pyomo.core.expr.compare import assertExpressionsEqual - +from pyomo.contrib.solver.solvers.ipopt import Ipopt +from pyomo.contrib.solver.solvers.knitro import KnitroDirectSolver from pyomo.contrib.solver.tests.solvers import instances +from pyomo.core.expr.compare import assertExpressionsEqual +from pyomo.core.expr.numeric_expr import LinearExpression np, numpy_available = attempt_import('numpy') parameterized, param_available = attempt_import('parameterized') From 46edd34c66754f8ad3a330820df0841592bd372e Mon Sep 17 00:00:00 2001 From: eminyouskn Date: Thu, 25 Sep 2025 12:20:08 -0400 Subject: [PATCH 54/64] Resolve mrmundt comments --- pyomo/contrib/solver/plugins.py | 8 ++++++-- pyomo/contrib/solver/solvers/knitro/__init__.py | 16 ---------------- 2 files changed, 6 insertions(+), 18 deletions(-) diff --git a/pyomo/contrib/solver/plugins.py b/pyomo/contrib/solver/plugins.py index e29faf9ec87..389466f38aa 100644 --- a/pyomo/contrib/solver/plugins.py +++ b/pyomo/contrib/solver/plugins.py @@ -15,7 +15,7 @@ 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 +from .solvers.knitro.direct import KnitroDirectSolver def load(): @@ -35,4 +35,8 @@ def load(): SolverFactory.register( name="highs", legacy_name="highs", doc="Persistent interface to HiGHS" )(Highs) - load_knitro() + SolverFactory.register( + name="knitro_direct", + legacy_name="knitro_direct", + doc="Direct interface to KNITRO solver", + )(KnitroDirectSolver) diff --git a/pyomo/contrib/solver/solvers/knitro/__init__.py b/pyomo/contrib/solver/solvers/knitro/__init__.py index 8f8db6e272a..6eb9ea8b81d 100644 --- a/pyomo/contrib/solver/solvers/knitro/__init__.py +++ b/pyomo/contrib/solver/solvers/knitro/__init__.py @@ -8,19 +8,3 @@ # 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() -> None: - SolverFactory.register( - name="knitro_direct", - legacy_name="knitro_direct", - doc="Direct interface to KNITRO solver", - )(KnitroDirectSolver) From a92848a9af2dc036572a9b275225ae30c162b0a5 Mon Sep 17 00:00:00 2001 From: eminyouskn Date: Thu, 25 Sep 2025 12:21:23 -0400 Subject: [PATCH 55/64] fix knitro import --- pyomo/contrib/solver/tests/solvers/test_solvers.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyomo/contrib/solver/tests/solvers/test_solvers.py b/pyomo/contrib/solver/tests/solvers/test_solvers.py index 86523de001d..f5585d50cc6 100644 --- a/pyomo/contrib/solver/tests/solvers/test_solvers.py +++ b/pyomo/contrib/solver/tests/solvers/test_solvers.py @@ -35,7 +35,7 @@ from pyomo.contrib.solver.solvers.gurobi_persistent import GurobiPersistent from pyomo.contrib.solver.solvers.highs import Highs from pyomo.contrib.solver.solvers.ipopt import Ipopt -from pyomo.contrib.solver.solvers.knitro import KnitroDirectSolver +from pyomo.contrib.solver.solvers.knitro.direct import KnitroDirectSolver from pyomo.contrib.solver.tests.solvers import instances from pyomo.core.expr.compare import assertExpressionsEqual from pyomo.core.expr.numeric_expr import LinearExpression From cc153d3180af8f345d14f72b1965d32e9b4950d9 Mon Sep 17 00:00:00 2001 From: eminyouskn Date: Thu, 25 Sep 2025 12:22:58 -0400 Subject: [PATCH 56/64] Fix knitro module usage. --- .../tests/solvers/test_knitro_direct.py | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/pyomo/contrib/solver/tests/solvers/test_knitro_direct.py b/pyomo/contrib/solver/tests/solvers/test_knitro_direct.py index 1334faaba68..21a93fa44c7 100644 --- a/pyomo/contrib/solver/tests/solvers/test_knitro_direct.py +++ b/pyomo/contrib/solver/tests/solvers/test_knitro_direct.py @@ -11,16 +11,17 @@ import pyomo.common.unittest as unittest -import pyomo.contrib.solver.solvers.knitro as knitro +from pyomo.contrib.solver.solvers.knitro.config import KnitroConfig +from pyomo.contrib.solver.solvers.knitro.direct import KnitroDirectSolver import pyomo.environ as pyo -avail = knitro.KnitroDirectSolver().available() +avail = KnitroDirectSolver().available() @unittest.skipIf(not avail, "KNITRO solver is not available") class TestKnitroDirectSolverConfig(unittest.TestCase): def test_default_instantiation(self): - config = knitro.KnitroConfig() + config = KnitroConfig() self.assertIsNone(config._description) self.assertEqual(config._visibility, 0) self.assertFalse(config.tee) @@ -32,7 +33,7 @@ def test_default_instantiation(self): self.assertIsNone(config.time_limit) def test_custom_instantiation(self): - config = knitro.KnitroConfig(description="A description") + config = KnitroConfig(description="A description") config.tee = True self.assertTrue(config.tee) self.assertEqual(config._description, "A description") @@ -42,7 +43,7 @@ def test_custom_instantiation(self): @unittest.skipIf(not avail, "KNITRO solver is not available") class TestKnitroDirectSolverInterface(unittest.TestCase): def test_class_member_list(self): - opt = knitro.KnitroDirectSolver() + opt = KnitroDirectSolver() expected_list = [ "CONFIG", "available", @@ -59,7 +60,7 @@ def test_class_member_list(self): self.assertListEqual(sorted(method_list), sorted(expected_list)) def test_default_instantiation(self): - opt = knitro.KnitroDirectSolver() + opt = KnitroDirectSolver() self.assertFalse(opt.is_persistent()) self.assertIsNotNone(opt.version()) self.assertEqual(opt.name, "knitro_direct") @@ -67,7 +68,7 @@ def test_default_instantiation(self): self.assertTrue(opt.available()) def test_instantiation_as_context(self): - with knitro.KnitroDirectSolver() as opt: + with KnitroDirectSolver() as opt: self.assertFalse(opt.is_persistent()) self.assertIsNotNone(opt.version()) self.assertEqual(opt.name, "knitro_direct") @@ -75,7 +76,7 @@ def test_instantiation_as_context(self): self.assertTrue(opt.available()) def test_available_cache(self): - opt = knitro.KnitroDirectSolver() + opt = KnitroDirectSolver() opt.available() self.assertTrue(opt._available_cache) self.assertIsNotNone(opt._available_cache) @@ -84,7 +85,7 @@ def test_available_cache(self): @unittest.skipIf(not avail, "KNITRO solver is not available") class TestKnitroDirectSolver(unittest.TestCase): def setUp(self): - self.opt = knitro.KnitroDirectSolver() + self.opt = KnitroDirectSolver() def test_solve(self): m = pyo.ConcreteModel() From ca3264fa3da5b9b7d9c4bf2ebec9569b8a7e08d0 Mon Sep 17 00:00:00 2001 From: eminyouskn Date: Thu, 25 Sep 2025 13:32:48 -0400 Subject: [PATCH 57/64] Update pyomo/contrib/solver/solvers/knitro/engine.py Co-authored-by: Miranda Mundt <55767766+mrmundt@users.noreply.github.com> --- pyomo/contrib/solver/solvers/knitro/engine.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyomo/contrib/solver/solvers/knitro/engine.py b/pyomo/contrib/solver/solvers/knitro/engine.py index 561c3ad734f..6cc74f7cfee 100644 --- a/pyomo/contrib/solver/solvers/knitro/engine.py +++ b/pyomo/contrib/solver/solvers/knitro/engine.py @@ -174,7 +174,7 @@ def __init__(self, *, nonlinear_diff_order: int = 2) -> None: self.maps = MappingProxyType({VarData: {}, ConstraintData: {}}) # Nonlinear map: # None -> objective nonlinear expression - # idx_con -> constranit nonlinear expression + # idx_con -> constraint nonlinear expression self.nonlinear_map = {} self.nonlinear_diff_order = nonlinear_diff_order self._kc = None From 5f49a67f44d739bd743f497d072f54ee73c16f5a Mon Sep 17 00:00:00 2001 From: eminyouskn Date: Thu, 25 Sep 2025 13:47:51 -0400 Subject: [PATCH 58/64] make Engine as context manager. --- pyomo/contrib/solver/solvers/knitro/engine.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/pyomo/contrib/solver/solvers/knitro/engine.py b/pyomo/contrib/solver/solvers/knitro/engine.py index 6cc74f7cfee..fac5173affc 100644 --- a/pyomo/contrib/solver/solvers/knitro/engine.py +++ b/pyomo/contrib/solver/solvers/knitro/engine.py @@ -153,6 +153,7 @@ def api_add_struct(is_obj: bool, structure_type: StructureType) -> Callable[..., return knitro.KN_add_con_linear_struct elif structure_type == StructureType.QUADRATIC: return knitro.KN_add_con_quadratic_struct + raise UnreachableError() class Engine: @@ -180,6 +181,13 @@ def __init__(self, *, nonlinear_diff_order: int = 2) -> None: self._kc = None self._status = None + def __enter__(self) -> "Engine": + self.renew() + return self + + def __exit__(self, exc_type, exc_value, traceback) -> None: + self.close() + def __del__(self) -> None: self.close() From 67a9a9251f1a163765465b10eca93559b519e7af Mon Sep 17 00:00:00 2001 From: eminyouskn Date: Thu, 25 Sep 2025 14:35:16 -0400 Subject: [PATCH 59/64] Use DeveloperError --- pyomo/contrib/solver/solvers/knitro/base.py | 11 +++-------- pyomo/contrib/solver/solvers/knitro/engine.py | 14 +++++++------- pyomo/contrib/solver/solvers/knitro/typing.py | 6 ------ 3 files changed, 10 insertions(+), 21 deletions(-) diff --git a/pyomo/contrib/solver/solvers/knitro/base.py b/pyomo/contrib/solver/solvers/knitro/base.py index a48f77a67b2..54161eff423 100644 --- a/pyomo/contrib/solver/solvers/knitro/base.py +++ b/pyomo/contrib/solver/solvers/knitro/base.py @@ -16,7 +16,7 @@ from typing import Optional from pyomo.common.collections import ComponentMap -from pyomo.common.errors import ApplicationError, PyomoException +from pyomo.common.errors import ApplicationError, DeveloperError, PyomoException from pyomo.common.numeric_types import value from pyomo.common.tee import TeeStream, capture_output from pyomo.common.timing import HierarchicalTimer @@ -41,12 +41,7 @@ SolutionLoader, SolutionProvider, ) -from pyomo.contrib.solver.solvers.knitro.typing import ( - ItemData, - ItemType, - UnreachableError, - ValueType, -) +from pyomo.contrib.solver.solvers.knitro.typing import ItemData, ItemType, ValueType from pyomo.contrib.solver.solvers.knitro.utils import KnitroModelData from pyomo.core.base.block import BlockData from pyomo.core.base.constraint import ConstraintData @@ -273,4 +268,4 @@ def _get_error_type( return NoReducedCostsError elif item_type is ConstraintData and value_type == ValueType.DUAL: return NoDualsError - raise UnreachableError() + raise DeveloperError() diff --git a/pyomo/contrib/solver/solvers/knitro/engine.py b/pyomo/contrib/solver/solvers/knitro/engine.py index fac5173affc..192570b2595 100644 --- a/pyomo/contrib/solver/solvers/knitro/engine.py +++ b/pyomo/contrib/solver/solvers/knitro/engine.py @@ -14,6 +14,7 @@ from typing import Any, Optional, TypeVar from pyomo.common.enums import ObjectiveSense +from pyomo.common.errors import DeveloperError from pyomo.common.numeric_types import value from pyomo.contrib.solver.solvers.knitro.api import knitro from pyomo.contrib.solver.solvers.knitro.callback import build_callback_handler @@ -24,7 +25,6 @@ ItemData, ItemType, StructureType, - UnreachableError, ValueType, ) from pyomo.contrib.solver.solvers.knitro.utils import NonlinearExpressionData @@ -85,7 +85,7 @@ def api_set_param(param_type: int) -> Callable[..., None]: return knitro.KN_set_double_param elif param_type == knitro.KN_PARAMTYPE_STRING: return knitro.KN_set_char_param - raise UnreachableError() + raise DeveloperError() def api_get_values( @@ -101,7 +101,7 @@ def api_get_values( return knitro.KN_get_con_dual_values elif value_type == ValueType.PRIMAL: return knitro.KN_get_con_values - raise UnreachableError() + raise DeveloperError() def api_add_items(item_type: type[ItemType]) -> Callable[..., Optional[list[int]]]: @@ -109,7 +109,7 @@ def api_add_items(item_type: type[ItemType]) -> Callable[..., Optional[list[int] return knitro.KN_add_vars elif item_type is ConstraintData: return knitro.KN_add_cons - raise UnreachableError() + raise DeveloperError() def api_set_bnds( @@ -129,13 +129,13 @@ def api_set_bnds( return knitro.KN_set_con_lobnds elif bound_type == BoundType.UP: return knitro.KN_set_con_upbnds - raise UnreachableError() + raise DeveloperError() def api_set_types(item_type: type[ItemType]) -> Callable[..., None]: if item_type is VarData: return knitro.KN_set_var_types - raise UnreachableError() + raise DeveloperError() def api_add_struct(is_obj: bool, structure_type: StructureType) -> Callable[..., None]: @@ -153,7 +153,7 @@ def api_add_struct(is_obj: bool, structure_type: StructureType) -> Callable[..., return knitro.KN_add_con_linear_struct elif structure_type == StructureType.QUADRATIC: return knitro.KN_add_con_quadratic_struct - raise UnreachableError() + raise DeveloperError() class Engine: diff --git a/pyomo/contrib/solver/solvers/knitro/typing.py b/pyomo/contrib/solver/solvers/knitro/typing.py index c44eb19e733..11575ab4467 100644 --- a/pyomo/contrib/solver/solvers/knitro/typing.py +++ b/pyomo/contrib/solver/solvers/knitro/typing.py @@ -69,9 +69,3 @@ class Callback(NamedTuple): func: CallbackFunction grad: CallbackFunction hess: CallbackFunction - - -class UnreachableError(Exception): - """Raised when code reaches a theoretically unreachable state.""" - - pass From ea3512050cdde6ea3fe09930d6c51198c1104671 Mon Sep 17 00:00:00 2001 From: eminyouskn Date: Thu, 25 Sep 2025 20:44:18 -0400 Subject: [PATCH 60/64] fix test_environ --- pyomo/contrib/solver/solvers/knitro/api.py | 7 ++++++- pyomo/contrib/solver/solvers/knitro/package.py | 4 ++-- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/pyomo/contrib/solver/solvers/knitro/api.py b/pyomo/contrib/solver/solvers/knitro/api.py index 4cd4f170f28..654f304e1ad 100644 --- a/pyomo/contrib/solver/solvers/knitro/api.py +++ b/pyomo/contrib/solver/solvers/knitro/api.py @@ -12,4 +12,9 @@ from pyomo.common.dependencies import attempt_import knitro, KNITRO_AVAILABLE = attempt_import("knitro") -KNITRO_VERSION = knitro.__version__ if bool(KNITRO_AVAILABLE) else "0.0.0" + + +def get_version(): + if bool(KNITRO_AVAILABLE): + return "0.0.0" + return knitro.__version__ diff --git a/pyomo/contrib/solver/solvers/knitro/package.py b/pyomo/contrib/solver/solvers/knitro/package.py index 3c52d833faf..ce10214805f 100644 --- a/pyomo/contrib/solver/solvers/knitro/package.py +++ b/pyomo/contrib/solver/solvers/knitro/package.py @@ -16,7 +16,7 @@ from pyomo.contrib.solver.common.base import Availability from pyomo.contrib.solver.solvers.knitro.api import ( KNITRO_AVAILABLE, - KNITRO_VERSION, + get_version, knitro, ) @@ -68,7 +68,7 @@ def get_version() -> tuple[int, int, int]: tuple[int, int, int]: The (major, minor, patch) version of KNITRO. """ - major, minor, patch = map(int, KNITRO_VERSION.split(".")) + major, minor, patch = map(int, get_version().split(".")) return major, minor, patch @staticmethod From b0a5a229ab19886377b60d2415d87da5bbab87c3 Mon Sep 17 00:00:00 2001 From: eminyouskn Date: Fri, 26 Sep 2025 00:25:28 -0400 Subject: [PATCH 61/64] fix typing and get_version --- pyomo/contrib/solver/solvers/knitro/api.py | 4 ++-- pyomo/contrib/solver/solvers/knitro/callback.py | 5 ++--- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/pyomo/contrib/solver/solvers/knitro/api.py b/pyomo/contrib/solver/solvers/knitro/api.py index 654f304e1ad..10d0cb9f7a9 100644 --- a/pyomo/contrib/solver/solvers/knitro/api.py +++ b/pyomo/contrib/solver/solvers/knitro/api.py @@ -14,7 +14,7 @@ knitro, KNITRO_AVAILABLE = attempt_import("knitro") -def get_version(): - if bool(KNITRO_AVAILABLE): +def get_version() -> str: + if not bool(KNITRO_AVAILABLE): return "0.0.0" return knitro.__version__ diff --git a/pyomo/contrib/solver/solvers/knitro/callback.py b/pyomo/contrib/solver/solvers/knitro/callback.py index 09192b988d1..6a9ddb71eb0 100644 --- a/pyomo/contrib/solver/solvers/knitro/callback.py +++ b/pyomo/contrib/solver/solvers/knitro/callback.py @@ -85,8 +85,7 @@ def hess(self, req: CallbackRequest, res: CallbackResult) -> int: return 0 -def build_callback_handler(function, *, idx: Optional[int] = None) -> CallbackHandler: - is_obj = idx is None - if is_obj: +def build_callback_handler(function: Function, idx: Optional[int]) -> CallbackHandler: + if idx is None: return ObjectiveCallbackHandler(function) return ConstraintCallbackHandler(idx, function) From daaf888340e1a9754166ec3d71f611798ae7b6ca Mon Sep 17 00:00:00 2001 From: eminyouskn Date: Mon, 29 Sep 2025 15:44:36 -0400 Subject: [PATCH 62/64] Add KNITRO to docs --- doc/OnlineDocs/explanation/experimental/solvers.rst | 3 +++ 1 file changed, 3 insertions(+) diff --git a/doc/OnlineDocs/explanation/experimental/solvers.rst b/doc/OnlineDocs/explanation/experimental/solvers.rst index b0d04fd1d9a..49ce353d6bf 100644 --- a/doc/OnlineDocs/explanation/experimental/solvers.rst +++ b/doc/OnlineDocs/explanation/experimental/solvers.rst @@ -54,6 +54,9 @@ with existing interfaces). * - HiGHS - ``highs`` - ``highs`` + * - KNITRO + - ``knitroampl`` + - ``knitro_direct`` Using the new interfaces through the legacy interface ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ From 618ad1619915a29981ae80cb0a6db1fe45071233 Mon Sep 17 00:00:00 2001 From: eminyouskn Date: Mon, 29 Sep 2025 18:06:39 -0400 Subject: [PATCH 63/64] Update doc/OnlineDocs/explanation/experimental/solvers.rst Co-authored-by: Miranda Mundt <55767766+mrmundt@users.noreply.github.com> --- doc/OnlineDocs/explanation/experimental/solvers.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/doc/OnlineDocs/explanation/experimental/solvers.rst b/doc/OnlineDocs/explanation/experimental/solvers.rst index 49ce353d6bf..05b754c4481 100644 --- a/doc/OnlineDocs/explanation/experimental/solvers.rst +++ b/doc/OnlineDocs/explanation/experimental/solvers.rst @@ -55,7 +55,7 @@ with existing interfaces). - ``highs`` - ``highs`` * - KNITRO - - ``knitroampl`` + - ``knitro_direct`` - ``knitro_direct`` Using the new interfaces through the legacy interface From d1133a0dac56e0e5c7cd3fad90243111b6d9c2ba Mon Sep 17 00:00:00 2001 From: eminyouskn Date: Tue, 7 Oct 2025 15:19:12 -0400 Subject: [PATCH 64/64] refactor version --- pyomo/contrib/solver/solvers/knitro/api.py | 6 ++++-- pyomo/contrib/solver/solvers/knitro/package.py | 12 ++++++++---- 2 files changed, 12 insertions(+), 6 deletions(-) diff --git a/pyomo/contrib/solver/solvers/knitro/api.py b/pyomo/contrib/solver/solvers/knitro/api.py index 10d0cb9f7a9..e2698f6c2a9 100644 --- a/pyomo/contrib/solver/solvers/knitro/api.py +++ b/pyomo/contrib/solver/solvers/knitro/api.py @@ -9,12 +9,14 @@ # This software is distributed under the 3-clause BSD License. # ___________________________________________________________________________ +from typing import Optional + from pyomo.common.dependencies import attempt_import knitro, KNITRO_AVAILABLE = attempt_import("knitro") -def get_version() -> str: +def get_version() -> Optional[str]: if not bool(KNITRO_AVAILABLE): - return "0.0.0" + return None return knitro.__version__ diff --git a/pyomo/contrib/solver/solvers/knitro/package.py b/pyomo/contrib/solver/solvers/knitro/package.py index ce10214805f..2298bb93071 100644 --- a/pyomo/contrib/solver/solvers/knitro/package.py +++ b/pyomo/contrib/solver/solvers/knitro/package.py @@ -61,14 +61,18 @@ def create_context(): return knitro.KN_new_lm(lmc) @staticmethod - def get_version() -> tuple[int, int, int]: + def get_version() -> Optional[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. + tuple[int, int, int]: The (major, minor, patch) version of KNITRO + or None if KNITRO version could not be determined. """ - major, minor, patch = map(int, get_version().split(".")) + version = get_version() + if version is None: + return None + major, minor, patch = map(int, version.split(".")) return major, minor, patch @staticmethod @@ -103,5 +107,5 @@ def available(self) -> Availability: self._available_cache = Package.check_availability() return self._available_cache - def version(self) -> tuple[int, int, int]: + def version(self) -> Optional[tuple[int, int, int]]: return Package.get_version()