diff --git a/doc/OnlineDocs/explanation/experimental/solvers.rst b/doc/OnlineDocs/explanation/experimental/solvers.rst index b0d04fd1d9a..05b754c4481 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 + - ``knitro_direct`` + - ``knitro_direct`` Using the new interfaces through the legacy interface ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ diff --git a/pyomo/contrib/solver/plugins.py b/pyomo/contrib/solver/plugins.py index 19fc9b2b2a1..389466f38aa 100644 --- a/pyomo/contrib/solver/plugins.py +++ b/pyomo/contrib/solver/plugins.py @@ -15,22 +15,28 @@ from .solvers.gurobi_persistent import GurobiPersistent from .solvers.gurobi_direct import GurobiDirect from .solvers.highs import Highs +from .solvers.knitro.direct import KnitroDirectSolver 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) + 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 new file mode 100644 index 00000000000..6eb9ea8b81d --- /dev/null +++ b/pyomo/contrib/solver/solvers/knitro/__init__.py @@ -0,0 +1,10 @@ +# ___________________________________________________________________________ +# +# 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. +# ___________________________________________________________________________ diff --git a/pyomo/contrib/solver/solvers/knitro/api.py b/pyomo/contrib/solver/solvers/knitro/api.py new file mode 100644 index 00000000000..e2698f6c2a9 --- /dev/null +++ b/pyomo/contrib/solver/solvers/knitro/api.py @@ -0,0 +1,22 @@ +# ___________________________________________________________________________ +# +# 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 typing import Optional + +from pyomo.common.dependencies import attempt_import + +knitro, KNITRO_AVAILABLE = attempt_import("knitro") + + +def get_version() -> Optional[str]: + if not bool(KNITRO_AVAILABLE): + return None + return knitro.__version__ diff --git a/pyomo/contrib/solver/solvers/knitro/base.py b/pyomo/contrib/solver/solvers/knitro/base.py new file mode 100644 index 00000000000..54161eff423 --- /dev/null +++ b/pyomo/contrib/solver/solvers/knitro/base.py @@ -0,0 +1,271 @@ +# ___________________________________________________________________________ +# +# 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 abc import abstractmethod +from collections.abc import 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, DeveloperError, PyomoException +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 SolverBase +from pyomo.contrib.solver.common.results import ( + Results, + SolutionStatus, + TerminationCondition, +) +from pyomo.contrib.solver.common.util import ( + IncompatibleModelError, + NoDualsError, + NoOptimalSolutionError, + 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, ValueType +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, SolverBase): + CONFIG = KnitroConfig() + config: KnitroConfig + + _engine: Engine + _model_data: KnitroModelData + _stream: StringIO + _saved_var_values: dict[int, Optional[float]] + + def __init__(self, **kwds) -> None: + PackageChecker.__init__(self) + SolverBase.__init__(self, **kwds) + self._engine = Engine() + self._model_data = KnitroModelData() + self._stream = StringIO() + self._saved_var_values = {} + + 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() + + 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) + + results.timing_info.start_timestamp = tick + results.timing_info.wall_time = (tock - tick).total_seconds() + return results + + def _build_config(self, **kwds) -> KnitroConfig: + return self.config(value=kwds, preserve_implicit=True) # type: ignore + + def _validate_problem(self) -> None: + if len(self._model_data.objs) > 1: + msg = f"{self.name} does not support multiple objectives." + raise IncompatibleModelError(msg) + + def _check_available(self) -> None: + avail = self.available() + if not avail: + msg = f"Solver {self.name} is not available: {avail}." + raise ApplicationError(msg) + + def _save_var_values(self) -> None: + 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) + for var in self._get_vars(): + var.set_value(self._saved_var_values[id(var)]) + StaleFlagManager.mark_all_as_stale() + + @abstractmethod + def _presolve( + self, model: BlockData, config: KnitroConfig, timer: HierarchicalTimer + ) -> None: + raise NotImplementedError + + @abstractmethod + def _solve(self, config: KnitroConfig, timer: HierarchicalTimer) -> None: + raise NotImplementedError + + def _postsolve(self, config: KnitroConfig, timer: HierarchicalTimer) -> Results: + status = self._engine.get_status() + results = Results() + results.solver_name = self.name + 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) + 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 + + if ( + config.raise_exception_on_nonoptimal_result + and results.termination_condition + != TerminationCondition.convergenceCriteriaSatisfied + ): + raise NoOptimalSolutionError() + + 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() + timer.stop("load_solutions") + + return results + + def get_values( + self, + item_type: type[ItemType], + value_type: ValueType, + items: Optional[Sequence[ItemType]] = None, + *, + exists: bool, + solution_id: Optional[int] = None, + ) -> Mapping[ItemType, float]: + 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: + raise error_type() + sign = value_type.sign + return ComponentMap([(k, sign * xk) for k, xk in zip(items, x)]) + + def get_num_solutions(self) -> int: + return self._engine.get_num_solutions() + + def _get_vars(self) -> list[VarData]: + return self._model_data.variables + + def _get_items(self, item_type: type[ItemType]) -> Sequence[ItemType]: + maps = { + VarData: self._model_data.variables, + ConstraintData: self._model_data.cons, + } + return maps[item_type] + + @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 + 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 + 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 + + @staticmethod + def _get_error_type( + item_type: type[ItemData], 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 DeveloperError() diff --git a/pyomo/contrib/solver/solvers/knitro/callback.py b/pyomo/contrib/solver/solvers/knitro/callback.py new file mode 100644 index 00000000000..6a9ddb71eb0 --- /dev/null +++ b/pyomo/contrib/solver/solvers/knitro/callback.py @@ -0,0 +1,91 @@ +# ___________________________________________________________________________ +# +# 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: Function, idx: Optional[int]) -> CallbackHandler: + if idx is None: + return ObjectiveCallbackHandler(function) + return ConstraintCallbackHandler(idx, function) diff --git a/pyomo/contrib/solver/solvers/knitro/config.py b/pyomo/contrib/solver/solvers/knitro/config.py new file mode 100644 index 00000000000..bdea4c9662f --- /dev/null +++ b/pyomo/contrib/solver/solvers/knitro/config.py @@ -0,0 +1,49 @@ +# ___________________________________________________________________________ +# +# 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, + ) -> None: + 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.", + ), + ) + + self.restore_variable_values_after_solve: bool = self.declare( + "restore_variable_values_after_solve", + ConfigValue( + domain=Bool, + 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 new file mode 100644 index 00000000000..8bab37032e3 --- /dev/null +++ b/pyomo/contrib/solver/solvers/knitro/direct.py @@ -0,0 +1,55 @@ +# ___________________________________________________________________________ +# +# 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.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 + + +class KnitroDirectSolver(KnitroSolverBase): + def _presolve( + self, model: BlockData, config: KnitroConfig, timer: HierarchicalTimer + ) -> None: + timer.start("build_problem") + 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._model_data.variables) + timer.stop("add_vars") + + timer.start("add_cons") + self._engine.add_cons(self._model_data.cons) + timer.stop("add_cons") + + if self._model_data.objs: + timer.start("set_objective") + self._engine.set_obj(self._model_data.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") + self._engine.solve() + timer.stop("solve") diff --git a/pyomo/contrib/solver/solvers/knitro/engine.py b/pyomo/contrib/solver/solvers/knitro/engine.py new file mode 100644 index 00000000000..192570b2595 --- /dev/null +++ b/pyomo/contrib/solver/solvers/knitro/engine.py @@ -0,0 +1,422 @@ +# ___________________________________________________________________________ +# +# 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, 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 +from pyomo.contrib.solver.solvers.knitro.package import Package +from pyomo.contrib.solver.solvers.knitro.typing import ( + BoundType, + Callback, + ItemData, + ItemType, + StructureType, + ValueType, +) +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 + + +def parse_bounds( + 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: + 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_types( + items: Iterable[ItemType], idx_map: Mapping[int, int] +) -> Mapping[int, int]: + 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: + return knitro.KN_set_double_param + elif param_type == knitro.KN_PARAMTYPE_STRING: + return knitro.KN_set_char_param + raise DeveloperError() + + +def api_get_values( + item_type: type[ItemType], 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 DeveloperError() + + +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: + return knitro.KN_add_cons + raise DeveloperError() + + +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 + 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 DeveloperError() + + +def api_set_types(item_type: type[ItemType]) -> Callable[..., None]: + if item_type is VarData: + return knitro.KN_set_var_types + raise DeveloperError() + + +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 + 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 + raise DeveloperError() + + +class Engine: + """A wrapper around the KNITRO API for a single optimization problem.""" + + has_objective: bool + maps: Mapping[type[ItemData], MutableMapping[int, int]] + nonlinear_map: MutableMapping[Optional[int], NonlinearExpressionData] + nonlinear_diff_order: int + + _kc: Optional[Any] + _status: Optional[int] + + def __init__(self, *, nonlinear_diff_order: int = 2) -> None: + 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 -> constraint nonlinear expression + self.nonlinear_map = {} + self.nonlinear_diff_order = nonlinear_diff_order + 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() + + def renew(self) -> None: + self.close() + self._kc = Package.create_context() + 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) + + def close(self) -> None: + if self._kc is not None: + self.execute(knitro.KN_free) + self._kc = None + + 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: Sequence[VarData]) -> None: + 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.set_bounds(ConstraintData, cons) + self.set_con_structures(cons) + + def set_obj(self, obj: ObjectiveData) -> None: + self.has_objective = True + self.set_obj_goal(obj.sense) + self.set_obj_structures(obj) + + def set_options(self, **options) -> None: + for param, val in options.items(): + self.set_option(param, val) + + 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) -> None: + self.set_options(maxtime_cpu=time_limit) + + def set_num_threads(self, nthreads: int) -> None: + self.set_options(threads=nthreads) + + def solve(self) -> int: + self.register_callbacks() + self._status = self.execute(knitro.KN_solve) + return self._status + + 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. No status is available!" + raise RuntimeError(msg) + return self._status + + 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_obj_value(self) -> Optional[float]: + 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[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[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) + 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 = api_set_param(param_type) + self.execute(func, param_id, val) + + 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) + + 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[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: + continue + + func = api_set_bnds(item_type, bound_type) + self.execute(func, bounds.keys(), bounds.values()) + + 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) + self.execute(func, types_map.keys(), types_map.values()) + + def set_con_structures(self, cons: Iterable[ConstraintData]) -> None: + for con in cons: + i = self.maps[ConstraintData][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: + return + + is_obj = i is None + base_args = () if is_obj else (i,) + structure_type_seq: list[StructureType] = [] + args_seq: list[tuple[Any, ...]] = [] + + if repn.constant is not None: + 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) + 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) + 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 = 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.maps[VarData], + diff_order=self.nonlinear_diff_order, + ) + + 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) + + 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, + ) + + 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 + ) + + 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, expr, callback) diff --git a/pyomo/contrib/solver/solvers/knitro/package.py b/pyomo/contrib/solver/solvers/knitro/package.py new file mode 100644 index 00000000000..2298bb93071 --- /dev/null +++ b/pyomo/contrib/solver/solvers/knitro/package.py @@ -0,0 +1,111 @@ +# ___________________________________________________________________________ +# +# 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 Optional + +from pyomo.common.tee import TeeStream, capture_output +from pyomo.contrib.solver.common.base import Availability +from pyomo.contrib.solver.solvers.knitro.api import ( + KNITRO_AVAILABLE, + get_version, + knitro, +) + + +class Package: + """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 Package._license_context is None: + Package._license_context = knitro.KN_checkout_license() + return Package._license_context + + @staticmethod + def release_license() -> None: + """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. + + Returns: + The new KNITRO context object. + + """ + lmc = Package.initialize_license() + return knitro.KN_new_lm(lmc) + + @staticmethod + 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 + or None if KNITRO version could not be determined. + + """ + version = get_version() + if version is None: + return None + major, minor, patch = map(int, version.split(".")) + return major, minor, patch + + @staticmethod + def check_availability() -> Availability: + """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 + try: + stream = io.StringIO() + 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. + return Availability.FullLicense + except Exception: + return Availability.BadLicense + + +class PackageChecker: + _available_cache: Optional[Availability] + + def __init__(self) -> None: + self._available_cache = None + + def available(self) -> Availability: + if self._available_cache is None: + self._available_cache = Package.check_availability() + return self._available_cache + + def version(self) -> Optional[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 new file mode 100644 index 00000000000..1222446a7a5 --- /dev/null +++ b/pyomo/contrib/solver/solvers/knitro/solution.py @@ -0,0 +1,93 @@ +# ___________________________________________________________________________ +# +# 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, 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 + + +class SolutionProvider(Protocol): + + def get_num_solutions(self) -> int: ... + def get_values( + self, + item_type: type[ItemType], + value_type: ValueType, + items: Optional[Sequence[ItemType]] = None, + *, + exists: bool, + solution_id: Optional[int] = None, + ) -> Mapping[ItemType, float]: ... + + +class SolutionLoader(SolutionLoaderBase): + _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() + + # 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, solution_id=None + ) -> Mapping[VarData, float]: + return self._provider.get_values( + VarData, + ValueType.PRIMAL, + vars_to_load, + exists=self.has_primals, + solution_id=solution_id, + ) + + def get_reduced_costs( + self, vars_to_load: Optional[Sequence[VarData]] = None, solution_id=None + ) -> Mapping[VarData, float]: + 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, solution_id=None + ) -> Mapping[ConstraintData, float]: + return self._provider.get_values( + ConstraintData, + ValueType.DUAL, + cons_to_load, + exists=self.has_duals, + solution_id=solution_id, + ) diff --git a/pyomo/contrib/solver/solvers/knitro/typing.py b/pyomo/contrib/solver/solvers/knitro/typing.py new file mode 100644 index 00000000000..11575ab4467 --- /dev/null +++ b/pyomo/contrib/solver/solvers/knitro/typing.py @@ -0,0 +1,71 @@ +# ___________________________________________________________________________ +# +# 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, NamedTuple, 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): + 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 + + +ItemData = Union[VarData, ConstraintData] +ItemType = TypeVar("ItemType", bound=ItemData) + + +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 CallbackRequest(Protocol): + x: list[float] + sigma: float + lambda_: list[float] + + +class CallbackResult(Protocol): + obj: float + c: list[float] + objGrad: list[float] + jac: list[float] + hess: list[float] + + +CallbackFunction = Callable[[Any, Any, CallbackRequest, CallbackResult, Any], int] + + +class Callback(NamedTuple): + func: CallbackFunction + grad: CallbackFunction + hess: CallbackFunction diff --git a/pyomo/contrib/solver/solvers/knitro/utils.py b/pyomo/contrib/solver/solvers/knitro/utils.py new file mode 100644 index 00000000000..4b570d6121f --- /dev/null +++ b/pyomo/contrib/solver/solvers/knitro/utils.py @@ -0,0 +1,272 @@ +# ___________________________________________________________________________ +# +# 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, 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.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 +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]: + """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 KnitroModelData: + """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] + _vars: MutableSet[VarData] + + def __init__(self, block: Optional[BlockData] = None) -> 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 = [] + self.variables = [] + if block is not None: + self.add_block(block) + + 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) -> None: + """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. + + 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) # 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) # type: ignore + for var in variables: + self._vars.add(var) + + # Update the variables list with unique variables only + self.variables = list(self._vars) + + +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(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. + + Attributes: + 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 + + """ + + 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: Expression, + variables: Iterable[VarData], + var_map: Mapping[int, int], + diff_order: int = 0, + ) -> None: + """Initialize NonlinearExpressionData. + + Args: + expr (Expression): The Pyomo expression to evaluate. + variables (Iterable[VarData]): Variables referenced in the expression. + diff_order (int): Level of differentiation to compute: + - 0: function evaluation only + - 1: function + gradient + - 2: function + gradient + hessian + + """ + self.func_expr = expr + self.variables = list(variables) + self.diff_order = diff_order + self._var_map = var_map + if diff_order >= 1: + self.compute_gradient() + if diff_order >= 2: + self.compute_hessian() + + @property + def grad_vars(self) -> list[VarData]: + """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. + + Returns: + list[tuple[VarData, VarData]]: Variable pairs with Hessian information. + + """ + return list(self.hess_map.keys()) + + 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.func_expr) + variables = ComponentSet(self.variables) + self.grad_map = ComponentMap() + for v, expr in derivative.items(): + if v in variables: + self.grad_map[v] = expr + + def compute_hessian(self) -> None: + """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. + + """ + variables = ComponentSet(self.variables) + 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_map: + self.hess_map[(var1, var2)] = hess_expr + + def evaluate(self, x: list[float]) -> float: + set_var_values(self.variables, x, self._var_map) + return value(self.func_expr) + + 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 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 new file mode 100644 index 00000000000..21a93fa44c7 --- /dev/null +++ b/pyomo/contrib/solver/tests/solvers/test_knitro_direct.py @@ -0,0 +1,158 @@ +# ___________________________________________________________________________ +# +# 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 pyomo.common.unittest as unittest + +from pyomo.contrib.solver.solvers.knitro.config import KnitroConfig +from pyomo.contrib.solver.solvers.knitro.direct import KnitroDirectSolver +import pyomo.environ as pyo + +avail = KnitroDirectSolver().available() + + +@unittest.skipIf(not avail, "KNITRO solver is not available") +class TestKnitroDirectSolverConfig(unittest.TestCase): + def test_default_instantiation(self): + config = 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 = 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 = KnitroDirectSolver() + expected_list = [ + "CONFIG", + "available", + "config", + "api_version", + "is_persistent", + "name", + "solve", + "version", + ] + 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): + opt = KnitroDirectSolver() + self.assertFalse(opt.is_persistent()) + self.assertIsNotNone(opt.version()) + self.assertEqual(opt.name, "knitro_direct") + self.assertEqual(opt.CONFIG, opt.config) + self.assertTrue(opt.available()) + + def test_instantiation_as_context(self): + with KnitroDirectSolver() as opt: + self.assertFalse(opt.is_persistent()) + self.assertIsNotNone(opt.version()) + self.assertEqual(opt.name, "knitro_direct") + self.assertEqual(opt.CONFIG, opt.config) + self.assertTrue(opt.available()) + + def test_available_cache(self): + opt = KnitroDirectSolver() + opt.available() + self.assertTrue(opt._available_cache) + self.assertIsNotNone(opt._available_cache) + + +@unittest.skipIf(not avail, "KNITRO solver is not available") +class TestKnitroDirectSolver(unittest.TestCase): + def setUp(self): + self.opt = KnitroDirectSolver() + + def test_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=(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): + 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(m.x.value, 5.0, 3) + self.assertAlmostEqual(m.y.value, 5.0, 3) + + 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.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) + + 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) diff --git a/pyomo/contrib/solver/tests/solvers/test_solvers.py b/pyomo/contrib/solver/tests/solvers/test_solvers.py index a3225f43d8a..f5585d50cc6 100644 --- a/pyomo/contrib/solver/tests/solvers/test_solvers.py +++ b/pyomo/contrib/solver/tests/solvers/test_solvers.py @@ -10,36 +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.core.expr.numeric_expr import LinearExpression -from pyomo.core.expr.compare import assertExpressionsEqual - +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.contrib.solver.solvers.ipopt import Ipopt +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 np, numpy_available = attempt_import('numpy') parameterized, param_available = attempt_import('parameterized') @@ -54,16 +53,25 @@ ('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), ('knitro_direct', KnitroDirectSolver)] +qcp_solvers = [ + ('gurobi_persistent', GurobiPersistent), + ('ipopt', Ipopt), + ('knitro_direct', KnitroDirectSolver), ] -nlp_solvers = [('ipopt', Ipopt)] -qcp_solvers = [('gurobi_persistent', GurobiPersistent), ('ipopt', Ipopt)] 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}