diff --git a/pyomo/solvers/plugins/solvers/ASL.py b/pyomo/solvers/plugins/solvers/ASL.py index c912f2a30ee..cd4462409ae 100644 --- a/pyomo/solvers/plugins/solvers/ASL.py +++ b/pyomo/solvers/plugins/solvers/ASL.py @@ -10,6 +10,7 @@ # ___________________________________________________________________________ +from pathlib import Path import os import subprocess @@ -17,6 +18,7 @@ from pyomo.common.errors import ApplicationError from pyomo.common.collections import Bunch from pyomo.common.tempfiles import TempfileManager +from pyomo.common.fileutils import find_executable from pyomo.opt.base import ProblemFormat, ResultsFormat from pyomo.opt.base.solvers import _extract_version, SolverFactory diff --git a/pyomo/solvers/plugins/solvers/KNITROAMPL.py b/pyomo/solvers/plugins/solvers/KNITROAMPL.py new file mode 100644 index 00000000000..179466076a5 --- /dev/null +++ b/pyomo/solvers/plugins/solvers/KNITROAMPL.py @@ -0,0 +1,87 @@ +# ___________________________________________________________________________ +# +# Pyomo: Python Optimization Modeling Objects +# Copyright (c) 2008-2024 +# 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 logging +from pathlib import Path + +from pyomo.common import Executable +from pyomo.common.collections import Bunch +from pyomo.opt.base.formats import ProblemFormat, ResultsFormat +from pyomo.opt.base.solvers import SolverFactory, OptSolver +from pyomo.solvers.plugins.solvers.ASL import ASL + +logger = logging.getLogger('pyomo.solvers') + + +@SolverFactory.register( + 'knitroampl', doc='The Knitro solver for NLP/MINLP and their subclasses' +) +class KNITROAMPL(ASL): + """An interface to the Knitro optimizer that uses the AMPL Solver Library.""" + + def __init__(self, **kwds): + """Constructor""" + executable = kwds.pop('executable', None) + validate = kwds.pop('validate', True) + kwds["type"] = "knitroampl" + kwds["options"] = {'solver': "knitroampl"} + OptSolver.__init__(self, **kwds) + self._keepfiles = False + self._results_file = None + self._timer = '' + self._user_executable = None + # broadly useful for reporting, and in cases where + # a solver plugin may not report execution time. + self._last_solve_time = None + self._define_signal_handlers = None + self._version_timeout = 2 + + if executable == 'knitroampl': + self.set_executable(name=None, validate=validate) + elif executable is not None: + self.set_executable(name=executable, validate=validate) + # + # Setup valid problem formats, and valid results for each problem format. + # Also set the default problem and results formats. + # + self._valid_problem_formats = [ProblemFormat.nl] + self._valid_result_formats = {ProblemFormat.nl: [ResultsFormat.sol]} + self.set_problem_format(ProblemFormat.nl) + # + # Note: Undefined capabilities default to 'None' + # + self._capabilities = Bunch() + self._capabilities.linear = True + self._capabilities.integer = True + self._capabilities.quadratic_objective = True + self._capabilities.quadratic_constraint = True + self._capabilities.sos1 = False + self._capabilities.sos2 = False + + def _default_executable(self): + try: + # If knitro Python package is available, use the executable it contains + import knitro + + package_knitroampl_path = ( + Path(knitro.__file__).resolve().parent / 'knitroampl' / 'knitroampl' + ) + executable = Executable(str(package_knitroampl_path)) + except ModuleNotFoundError: + # Otherwise, search usual path list + executable = Executable('knitroampl') + if not executable: + logger.warning( + "Could not locate the 'knitroampl' executable, " + "which is required for solver %s" % self.name + ) + self.enable = False + return None + return executable.path() diff --git a/pyomo/solvers/plugins/solvers/__init__.py b/pyomo/solvers/plugins/solvers/__init__.py index 61f92180abc..fcb45fc219c 100644 --- a/pyomo/solvers/plugins/solvers/__init__.py +++ b/pyomo/solvers/plugins/solvers/__init__.py @@ -32,4 +32,5 @@ xpress_direct, xpress_persistent, SAS, + KNITROAMPL, ) diff --git a/pyomo/solvers/tests/checks/test_KNITROAMPL.py b/pyomo/solvers/tests/checks/test_KNITROAMPL.py new file mode 100644 index 00000000000..37d7d759fbd --- /dev/null +++ b/pyomo/solvers/tests/checks/test_KNITROAMPL.py @@ -0,0 +1,141 @@ +# ___________________________________________________________________________ +# +# Pyomo: Python Optimization Modeling Objects +# Copyright (c) 2008-2024 +# 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 import unittest +from pyomo.environ import ( + ConcreteModel, + Var, + Objective, + Constraint, + Suffix, + Integers, + NonNegativeIntegers, + NonNegativeReals, + value, +) +from pyomo.opt import SolverFactory, TerminationCondition, SolutionStatus + +knitroampl_available = SolverFactory('knitroampl').available(False) + + +class TestKNITROAMPLInterface(unittest.TestCase): + @unittest.skipIf( + not knitroampl_available, "The 'knitroampl' command is not available" + ) + def test_infeasible_lp(self): + with SolverFactory('knitroampl') as opt: + model = ConcreteModel() + model.X = Var(within=NonNegativeReals) + model.C1 = Constraint(expr=model.X == 1) + model.C2 = Constraint(expr=model.X == 2) + model.Obj = Objective(expr=model.X) + + results = opt.solve(model) + + self.assertEqual( + results.solver.termination_condition, TerminationCondition.infeasible + ) + + @unittest.skipIf( + not knitroampl_available, "The 'knitroampl' command is not available" + ) + def test_unbounded_lp(self): + with SolverFactory('knitroampl') as opt: + model = ConcreteModel() + model.X = Var() + model.Obj = Objective(expr=model.X) + + results = opt.solve(model) + + self.assertIn( + results.solver.termination_condition, + ( + TerminationCondition.unbounded, + TerminationCondition.infeasibleOrUnbounded, + ), + ) + + @unittest.skipIf( + not knitroampl_available, "The 'knitroampl' command is not available" + ) + def test_optimal_lp(self): + with SolverFactory('knitroampl') as opt: + model = ConcreteModel() + model.X = Var(within=NonNegativeReals) + model.C1 = Constraint(expr=model.X >= 2.5) + model.Obj = Objective(expr=model.X) + + results = opt.solve(model, load_solutions=True) + + self.assertEqual( + results.solver.termination_condition, TerminationCondition.optimal + ) + self.assertAlmostEqual(value(model.X), 2.5) + + @unittest.skipIf( + not knitroampl_available, "The 'knitroampl' command is not available" + ) + def test_get_duals_lp(self): + with SolverFactory('knitroampl') as opt: + model = ConcreteModel() + model.X = Var(within=NonNegativeReals) + model.Y = Var(within=NonNegativeReals) + + model.C1 = Constraint(expr=2 * model.X + model.Y >= 8) + model.C2 = Constraint(expr=model.X + 3 * model.Y >= 6) + + model.Obj = Objective(expr=model.X + model.Y) + + results = opt.solve(model, suffixes=['dual'], load_solutions=False) + + model.dual = Suffix(direction=Suffix.IMPORT) + model.solutions.load_from(results) + + self.assertAlmostEqual(model.dual[model.C1], 0.4) + self.assertAlmostEqual(model.dual[model.C2], 0.2) + + @unittest.skipIf( + not knitroampl_available, "The 'knitroampl' command is not available" + ) + def test_infeasible_mip(self): + with SolverFactory('knitroampl') as opt: + model = ConcreteModel() + model.X = Var(within=NonNegativeIntegers) + model.C1 = Constraint(expr=model.X == 1) + model.C2 = Constraint(expr=model.X == 2) + model.Obj = Objective(expr=model.X) + + results = opt.solve(model) + + self.assertEqual( + results.solver.termination_condition, TerminationCondition.infeasible + ) + + @unittest.skipIf( + not knitroampl_available, "The 'knitroampl' command is not available" + ) + def test_optimal_mip(self): + with SolverFactory('knitroampl') as opt: + model = ConcreteModel() + model.X = Var(within=NonNegativeIntegers) + model.C1 = Constraint(expr=model.X >= 2.5) + model.Obj = Objective(expr=model.X) + + results = opt.solve(model, load_solutions=True) + + self.assertEqual( + results.solver.termination_condition, TerminationCondition.optimal + ) + self.assertAlmostEqual(value(model.X), 3) + + +if __name__ == "__main__": + unittest.main() diff --git a/pyomo/solvers/tests/mip/test4.soln b/pyomo/solvers/tests/mip/test4.soln new file mode 100644 index 00000000000..d73cf923fe7 --- /dev/null +++ b/pyomo/solvers/tests/mip/test4.soln @@ -0,0 +1,3 @@ +MILP solution: +Objective value = 2 +x2 = 1 diff --git a/pyomo/solvers/tests/testcases.py b/pyomo/solvers/tests/testcases.py index f0ea27136b6..0011f411f8b 100644 --- a/pyomo/solvers/tests/testcases.py +++ b/pyomo/solvers/tests/testcases.py @@ -285,8 +285,17 @@ # # KNITROAMPL # -# NO EXPECTED FAILURES -# +for prob in ('LP_trivial_constraints', 'LP_trivial_constraints_kernel'): + ExpectedFailures['knitroampl', 'nl', prob] = ( + lambda v: True, + 'Knitro does not consider tight trivial constraints to have zero dual value', + ) + +for prob in ('MILP_unbounded', 'MILP_unbounded_kernel'): + ExpectedFailures['knitroampl', 'nl', prob] = ( + lambda v: v[:2] <= (14, 2), + 'Unbounded MILP detection not operational in Knitro, fixed in 15.0', + ) def generate_scenarios(arg=None):