diff --git a/pyomo/contrib/solver/plugins.py b/pyomo/contrib/solver/plugins.py index a06bc2d64c1..56b48489398 100644 --- a/pyomo/contrib/solver/plugins.py +++ b/pyomo/contrib/solver/plugins.py @@ -15,6 +15,7 @@ from .solvers.gurobi.gurobi_direct_minlp import GurobiDirectMINLP from .solvers.highs import Highs from .solvers.knitro.direct import KnitroDirectSolver +from .solvers.knitro.persistent import KnitroPersistentSolver def load(): @@ -32,9 +33,9 @@ def load(): doc="Direct (scipy-based) interface to Gurobi", )(GurobiDirect) SolverFactory.register( - name='gurobi_direct_minlp', - legacy_name='gurobi_direct_minlp', - doc='Direct interface to Gurobi accommodating general MINLP', + name="gurobi_direct_minlp", + legacy_name="gurobi_direct_minlp", + doc="Direct interface to Gurobi accommodating general MINLP", )(GurobiDirectMINLP) SolverFactory.register( name="highs", legacy_name="highs", doc="Persistent interface to HiGHS" @@ -44,3 +45,8 @@ def load(): legacy_name="knitro_direct", doc="Direct interface to KNITRO solver", )(KnitroDirectSolver) + SolverFactory.register( + name="knitro_persistent", + legacy_name="knitro_persistent", + doc="Persistent interface to KNITRO solver", + )(KnitroPersistentSolver) diff --git a/pyomo/contrib/solver/solvers/knitro/base.py b/pyomo/contrib/solver/solvers/knitro/base.py index 2971034351a..1f92645d616 100644 --- a/pyomo/contrib/solver/solvers/knitro/base.py +++ b/pyomo/contrib/solver/solvers/knitro/base.py @@ -8,7 +8,7 @@ # ____________________________________________________________________________________ from abc import abstractmethod -from collections.abc import Mapping, Sequence +from collections.abc import Iterable, Mapping, Sequence import datetime import time from io import StringIO @@ -47,8 +47,6 @@ class KnitroSolverBase(SolutionProvider, PackageChecker, SolverBase): - CONFIG = KnitroConfig() - config: KnitroConfig _engine: Engine _model_data: KnitroModelData @@ -95,7 +93,7 @@ def solve(self, model: BlockData, **kwds) -> Results: return results def _build_config(self, **kwds) -> KnitroConfig: - return self.config(value=kwds, preserve_implicit=True) # type: ignore + return self.config(value=kwds, preserve_implicit=True) def _validate_problem(self) -> None: if len(self._model_data.objs) > 1: @@ -202,7 +200,7 @@ def get_num_solutions(self) -> int: def _get_vars(self) -> list[VarData]: return self._model_data.variables - def _get_items(self, item_type: type[ItemType]) -> Sequence[ItemType]: + def _get_items(self, item_type: type[ItemType]) -> Iterable[ItemType]: maps = { VarData: self._model_data.variables, ConstraintData: self._model_data.cons, diff --git a/pyomo/contrib/solver/solvers/knitro/config.py b/pyomo/contrib/solver/solvers/knitro/config.py index b7fc4135a30..eeed17b3529 100644 --- a/pyomo/contrib/solver/solvers/knitro/config.py +++ b/pyomo/contrib/solver/solvers/knitro/config.py @@ -8,10 +8,12 @@ # ____________________________________________________________________________________ from pyomo.common.config import Bool, ConfigValue -from pyomo.contrib.solver.common.config import SolverConfig +from pyomo.contrib.solver.common.config import PersistentSolverConfig, SolverConfig class KnitroConfig(SolverConfig): + """Configuration for the direct Knitro solver interface.""" + def __init__( self, description=None, @@ -28,31 +30,55 @@ def __init__( visibility=visibility, ) - self.rebuild_model_on_remove_var: bool = self.declare( - "rebuild_model_on_remove_var", + self.restore_variable_values_after_solve: bool = self.declare( + "restore_variable_values_after_solve", 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." + "To evaluate non-linear constraints, KNITRO solver sets " + "explicit values on variables. This option controls " + "whether to restore the original variable values after " + "solving." ), ), ) - self.restore_variable_values_after_solve: bool = self.declare( - "restore_variable_values_after_solve", + +class KnitroPersistentConfig(KnitroConfig, PersistentSolverConfig): + """Configuration for the persistent Knitro solver interface. + + Extends KnitroConfig with persistent solver capabilities including + auto_updates configuration. + """ + + 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=( - "To evaluate non-linear constraints, KNITRO solver sets " - "explicit values on variables. This option controls " - "whether to restore the original variable values after " - "solving." + "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 index 8df25d115ac..3c628884899 100644 --- a/pyomo/contrib/solver/solvers/knitro/direct.py +++ b/pyomo/contrib/solver/solvers/knitro/direct.py @@ -15,6 +15,9 @@ class KnitroDirectSolver(KnitroSolverBase): + CONFIG = KnitroConfig() + config: KnitroConfig + def _presolve( self, model: BlockData, config: KnitroConfig, timer: HierarchicalTimer ) -> None: diff --git a/pyomo/contrib/solver/solvers/knitro/persistent.py b/pyomo/contrib/solver/solvers/knitro/persistent.py new file mode 100644 index 00000000000..9166753e4c9 --- /dev/null +++ b/pyomo/contrib/solver/solvers/knitro/persistent.py @@ -0,0 +1,133 @@ +# ___________________________________________________________________________ +# +# 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.common.base import PersistentSolverBase +from pyomo.contrib.solver.solvers.knitro.base import KnitroSolverBase +from pyomo.contrib.solver.solvers.knitro.config import KnitroPersistentConfig +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.objective import ObjectiveData +from pyomo.core.base.param import ParamData +from pyomo.core.base.var import VarData + + +class KnitroPersistentSolver(KnitroSolverBase, PersistentSolverBase): + CONFIG = KnitroPersistentConfig() + config: KnitroPersistentConfig + + _model: BlockData | None + _staged_model_data: KnitroModelData + + def __init__(self, **kwds) -> None: + PersistentSolverBase.__init__(self, **kwds) + KnitroSolverBase.__init__(self, **kwds) + self._model = None + self._staged_model_data = KnitroModelData() + + def _presolve( + self, model: BlockData, config: KnitroPersistentConfig, timer: HierarchicalTimer + ) -> None: + if self._model is not model: + self.set_instance(model) + self._staged_model_data.clear() + + if self._staged_model_data: + self._update() + + def _solve(self, config: KnitroPersistentConfig, timer: HierarchicalTimer) -> None: + 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") + + def set_instance(self, model: BlockData): + if self._model is model: + return + self._model = model + self._model_data.set_block(model) + self._engine.renew() + self._engine.add_vars(self._model_data.variables) + self._engine.add_cons(self._model_data.cons) + if self._model_data.objs: + self._engine.set_obj(self._model_data.objs[0]) + + def add_block(self, block: BlockData): + self._staged_model_data.add_block(block, clear_objs=True) + + def add_variables(self, variables: list[VarData]): + self._staged_model_data.add_vars(variables) + + def add_constraints(self, cons: list[ConstraintData]): + self._staged_model_data.add_cons(cons, existing_vars=self._model_data.variables) + + def set_objective(self, obj: ObjectiveData): + self._staged_model_data.objs.clear() + self._staged_model_data.objs.append(obj) + + def _update(self): + self._model_data.add_vars(self._staged_model_data.variables) + self._model_data.add_cons(self._staged_model_data.cons) + + self._engine.add_vars(self._staged_model_data.variables) + self._engine.add_cons(self._staged_model_data.cons) + + if self._staged_model_data.objs: + self._model_data.objs.clear() + self._model_data.objs.extend(self._staged_model_data.objs) + self._engine.set_obj(self._model_data.objs[0]) + + self._staged_model_data.clear() + + def remove_variables(self, variables: list[VarData]) -> None: + raise NotImplementedError( + "KnitroPersistentSolver does not support removing variables." + ) + + def remove_constraints(self, cons: list[ConstraintData]) -> None: + raise NotImplementedError( + "KnitroPersistentSolver does not support removing constraints." + ) + + def update_variables(self, variables: list[VarData]) -> None: + raise NotImplementedError( + "KnitroPersistentSolver does not support updating variables." + ) + + def update_parameters(self) -> None: + raise NotImplementedError( + "KnitroPersistentSolver does not support updating parameters." + ) + + def add_parameters(self, params: list[ParamData]) -> None: + raise NotImplementedError( + "KnitroPersistentSolver does not support adding parameters." + ) + + def remove_parameters(self, params: list[ParamData]) -> None: + raise NotImplementedError( + "KnitroPersistentSolver does not support removing parameters." + ) + + def remove_block(self, block: BlockData) -> None: + raise NotImplementedError( + "KnitroPersistentSolver does not support removing blocks." + ) diff --git a/pyomo/contrib/solver/solvers/knitro/utils.py b/pyomo/contrib/solver/solvers/knitro/utils.py index 65448359cec..700d5a18816 100644 --- a/pyomo/contrib/solver/solvers/knitro/utils.py +++ b/pyomo/contrib/solver/solvers/knitro/utils.py @@ -69,7 +69,6 @@ class KnitroModelData: objs: list[ObjectiveData] cons: list[ConstraintData] - variables: list[VarData] _vars: MutableSet[VarData] def __init__(self, block: BlockData | None = None) -> None: @@ -83,7 +82,6 @@ def __init__(self, block: BlockData | None = None) -> None: self._vars = ComponentSet() self.objs = [] self.cons = [] - self.variables = [] if block is not None: self.add_block(block) @@ -91,7 +89,6 @@ 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: @@ -104,32 +101,77 @@ def set_block(self, block: BlockData) -> None: self.clear() self.add_block(block) - def add_block(self, block: BlockData) -> None: + def add_block(self, block: BlockData, *, clear_objs: bool = False) -> None: """Add objectives, constraints, and variables from a block to the problem. Args: block (BlockData): The Pyomo block to extract data from. + clear_objs (bool): Whether to clear the objectives before adding new ones. """ new_objs = get_active_objectives(block) new_cons = get_active_constraints(block) + if clear_objs and self.objs: + self.objs.clear() 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 + _, variables, _, _ = collect_vars_and_named_exprs(obj.expr) 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 + _, variables, _, _ = collect_vars_and_named_exprs(con.body) for var in variables: self._vars.add(var) - # Update the variables list with unique variables only - self.variables = list(self._vars) + def add_vars(self, variables: Iterable[VarData]) -> None: + """Add variables to the problem. + + Args: + variables (list[VarData]): The list of variables to add. + + """ + for var in variables: + self._vars.add(var) + + def add_cons( + self, + cons: Iterable[ConstraintData], + *, + existing_vars: MutableSet[VarData] | None = None, + ) -> None: + """Add constraints to the problem. + + Args: + cons (list[ConstraintData]): The list of constraints to add. + existing_vars (MutableSet[VarData] | None): Existing variable set to check + for already-tracked variables. New variables will be added to this + instance's internal set. + + """ + self.cons.extend(cons) + for con in cons: + _, variables, _, _ = collect_vars_and_named_exprs(con.body) + for var in variables: + if existing_vars is None or var not in existing_vars: + self._vars.add(var) + + @property + def variables(self) -> MutableSet[VarData]: + """Get the list of variables in the problem. + + Returns: + MutableSet[VarData]: The set of variables. + + """ + return self._vars + + def __bool__(self) -> bool: + return bool(self.objs or self.cons or self.variables) def set_var_values( diff --git a/pyomo/contrib/solver/tests/solvers/test_knitro_persistent.py b/pyomo/contrib/solver/tests/solvers/test_knitro_persistent.py new file mode 100644 index 00000000000..068dc25b81c --- /dev/null +++ b/pyomo/contrib/solver/tests/solvers/test_knitro_persistent.py @@ -0,0 +1,115 @@ +# ___________________________________________________________________________ +# +# 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 +import pyomo.environ as pyo +from pyomo.contrib.solver.solvers.knitro.persistent import KnitroPersistentSolver + +avail = KnitroPersistentSolver().available() + + +@unittest.skipIf(not avail, "KNITRO solver is not available") +class TestKnitroPersistentSolver(unittest.TestCase): + def setUp(self): + self.opt = KnitroPersistentSolver() + + def test_basics(self): + self.assertTrue(self.opt.is_persistent()) + self.assertEqual(self.opt.name, "knitro_persistent") + self.assertTrue(self.opt.available()) + + 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 + ) + self.opt.set_instance(m) + 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_incremental_add_variables(self): + m = pyo.ConcreteModel() + m.x = pyo.Var(initialize=1.5, bounds=(-5, 5)) + self.opt.add_variables([m.x]) + + # Add variable y incrementally + m.y = pyo.Var(initialize=1.5, bounds=(-5, 5)) + self.opt.add_variables([m.y]) + + # Add objective + m.obj = pyo.Objective( + expr=(1.0 - m.x) + 100.0 * (m.y - m.x), sense=pyo.minimize + ) + self.opt.set_objective(m.obj) + 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_incremental_add_constraints(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) + + self.opt.set_instance(m) + + # Add constraint incrementally + m.c1 = pyo.Constraint(expr=m.x**2 + m.y**2 <= 4) + self.opt.add_constraints([m.c1]) + + results = self.opt.solve(m) + self.assertAlmostEqual(results.incumbent_objective, 0.0) + # Check feasibility + self.assertTrue(pyo.value(m.x) ** 2 + pyo.value(m.y) ** 2 <= 4.0001) + + def test_incremental_add_block(self): + m = pyo.ConcreteModel() + m.x = pyo.Var(initialize=0, bounds=(-5, 5)) + m.obj = pyo.Objective(expr=m.x, sense=pyo.minimize) + self.opt.set_instance(m) + + m.b = pyo.Block() + m.b.y = pyo.Var(initialize=0, bounds=(-5, 5)) + m.b.c = pyo.Constraint(expr=m.b.y >= m.x) + + self.opt.add_block(m.b) + + # Update objective to include y + m.obj.expr += m.b.y + self.opt.set_objective(m.obj) + + self.opt.solve(m) + # min x + y s.t. y >= x, -5<=x<=5, -5<=y<=5 + # x=-5, y=-5 => obj = -10 + self.assertAlmostEqual(m.x.value, -5) + self.assertAlmostEqual(m.b.y.value, -5) + + def test_incremental_set_objective(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.x - m.y) ** 2, sense=pyo.minimize) + + self.opt.set_objective(m.obj) + + # Add constraint incrementally + m.c1 = pyo.Constraint(expr=m.x**2 + m.y**2 <= 4) + self.opt.add_constraints([m.c1]) + + results = self.opt.solve(m) + self.assertAlmostEqual(results.incumbent_objective, 0) + # Check feasibility + self.assertTrue(pyo.value(m.x) ** 2 + pyo.value(m.y) ** 2 <= 4.0001) diff --git a/pyomo/contrib/solver/tests/solvers/test_solvers.py b/pyomo/contrib/solver/tests/solvers/test_solvers.py index 9774e08d664..2b4a8af2257 100644 --- a/pyomo/contrib/solver/tests/solvers/test_solvers.py +++ b/pyomo/contrib/solver/tests/solvers/test_solvers.py @@ -40,6 +40,7 @@ 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.solvers.knitro.persistent import KnitroPersistentSolver from pyomo.contrib.solver.tests.solvers import instances from pyomo.core.expr.compare import assertExpressionsEqual from pyomo.core.expr.numeric_expr import LinearExpression @@ -73,6 +74,7 @@ def param_as_standalone_func(cls, p, func, name): ('ipopt', Ipopt), ('highs', Highs), ('knitro_direct', KnitroDirectSolver), + ('knitro_persistent', KnitroPersistentSolver), ] mip_solvers = [ ('gurobi_persistent', GurobiPersistent), @@ -80,31 +82,37 @@ def param_as_standalone_func(cls, p, func, name): ('gurobi_direct_minlp', GurobiDirectMINLP), ('highs', Highs), ('knitro_direct', KnitroDirectSolver), + ('knitro_persistent', KnitroPersistentSolver), ] nlp_solvers = [ ('gurobi_direct_minlp', GurobiDirectMINLP), ('ipopt', Ipopt), ('knitro_direct', KnitroDirectSolver), + ('knitro_persistent', KnitroPersistentSolver), ] qcp_solvers = [ ('gurobi_persistent', GurobiPersistent), ('gurobi_direct_minlp', GurobiDirectMINLP), ('ipopt', Ipopt), ('knitro_direct', KnitroDirectSolver), + ('knitro_persistent', KnitroPersistentSolver), ] qp_solvers = qcp_solvers + [("highs", Highs)] miqcqp_solvers = [ ('gurobi_direct_minlp', GurobiDirectMINLP), ('gurobi_persistent', GurobiPersistent), ('knitro_direct', KnitroDirectSolver), + ('knitro_persistent', KnitroPersistentSolver), ] nl_solvers = [('ipopt', Ipopt)] nl_solvers_set = {i[0] for i in nl_solvers} -def _load_tests(solver_list): +def _load_tests(solver_list, skip=None): res = list() for solver_name, solver in solver_list: + if skip and solver_name in skip: + continue if solver_name in nl_solvers_set: test_name = f"{solver_name}_presolve" res.append((test_name, solver, True)) @@ -2168,7 +2176,7 @@ def test_presolve_with_zero_coef( opt.config.writer_config.linear_presolve = False """ - when c2 gets presolved out, c1 becomes + when c2 gets presolved out, c1 becomes x - y + y = 0 which becomes x - 0*y == 0 which is the zero we are testing for """