From 7cb1cf3655e46306cf902f07be13166b3c28006a Mon Sep 17 00:00:00 2001 From: Ramakrishna Prabhu Date: Thu, 16 Apr 2026 15:51:03 -0500 Subject: [PATCH 1/4] Handle cuOpt UnboundedOrInfeasible termination status (11) cuOpt added a new termination status (value 11, UnboundedOrInfeasible) that the PSLP presolver returns when it cannot disambiguate infeasibility from unboundedness. The cuopt_direct plugin's status cascade did not recognize it, falling through to TerminationCondition.error and failing any LP_unbounded test. Adds an elif branch mapping status 11 to TerminationCondition. infeasibleOrUnbounded (with SolverStatus.warning / SolutionStatus.unsure). Also extends the status-code comment block to include status 10 (WorkLimit) and 11 (UnboundedOrInfeasible) for documentation. Tracked in NVIDIA/cuopt#1114. Signed-off-by: Ramakrishna Prabhu --- pyomo/solvers/plugins/solvers/cuopt_direct.py | 28 ++++++++++++------- 1 file changed, 18 insertions(+), 10 deletions(-) diff --git a/pyomo/solvers/plugins/solvers/cuopt_direct.py b/pyomo/solvers/plugins/solvers/cuopt_direct.py index 87f16b861c9..4ccdc9e631d 100644 --- a/pyomo/solvers/plugins/solvers/cuopt_direct.py +++ b/pyomo/solvers/plugins/solvers/cuopt_direct.py @@ -249,16 +249,18 @@ def _postsolve(self): is_mip = solution.get_problem_category() # Termination Status - # 0 - CUOPT_TERIMINATION_STATUS_NO_TERMINATION - # 1 - CUOPT_TERIMINATION_STATUS_OPTIMAL - # 2 - CUOPT_TERIMINATION_STATUS_INFEASIBLE - # 3 - CUOPT_TERIMINATION_STATUS_UNBOUNDED - # 4 - CUOPT_TERIMINATION_STATUS_ITERATION_LIMIT - # 5 - CUOPT_TERIMINATION_STATUS_TIME_LIMIT - # 6 - CUOPT_TERIMINATION_STATUS_NUMERICAL_ERROR - # 7 - CUOPT_TERIMINATION_STATUS_PRIMAL_FEASIBLE - # 8 - CUOPT_TERIMINATION_STATUS_FEASIBLE_FOUND - # 9 - CUOPT_TERIMINATION_STATUS_CONCURRENT_LIMIT + # 0 - CUOPT_TERIMINATION_STATUS_NO_TERMINATION + # 1 - CUOPT_TERIMINATION_STATUS_OPTIMAL + # 2 - CUOPT_TERIMINATION_STATUS_INFEASIBLE + # 3 - CUOPT_TERIMINATION_STATUS_UNBOUNDED + # 4 - CUOPT_TERIMINATION_STATUS_ITERATION_LIMIT + # 5 - CUOPT_TERIMINATION_STATUS_TIME_LIMIT + # 6 - CUOPT_TERIMINATION_STATUS_NUMERICAL_ERROR + # 7 - CUOPT_TERIMINATION_STATUS_PRIMAL_FEASIBLE + # 8 - CUOPT_TERIMINATION_STATUS_FEASIBLE_FOUND + # 9 - CUOPT_TERIMINATION_STATUS_CONCURRENT_LIMIT + # 10 - CUOPT_TERIMINATION_STATUS_WORK_LIMIT + # 11 - CUOPT_TERIMINATION_STATUS_UNBOUNDED_OR_INFEASIBLE if status == 1: self.results.solver.status = SolverStatus.ok @@ -292,6 +294,12 @@ def _postsolve(self): self.results.solver.status = SolverStatus.ok self.results.solver.termination_condition = TerminationCondition.other soln.status = SolutionStatus.other + elif status == 11: + self.results.solver.status = SolverStatus.warning + self.results.solver.termination_condition = ( + TerminationCondition.infeasibleOrUnbounded + ) + soln.status = SolutionStatus.unsure else: self.results.solver.status = SolverStatus.error self.results.solver.termination_condition = TerminationCondition.error From dc66798fd7eefdd9e8a8b2eb119e72ba3db0f943 Mon Sep 17 00:00:00 2001 From: Ramakrishna Prabhu Date: Fri, 17 Apr 2026 13:19:29 -0500 Subject: [PATCH 2/4] Add test exercising cuOpt UnboundedOrInfeasible status (11) An unbounded LP with no variable bounds triggers cuOpt's presolver to return UnboundedOrInfeasible (status 11); the test asserts the plugin maps it to TerminationCondition.infeasibleOrUnbounded, SolverStatus.warning, and SolutionStatus.unsure. --- .../solvers/tests/checks/test_cuopt_direct.py | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/pyomo/solvers/tests/checks/test_cuopt_direct.py b/pyomo/solvers/tests/checks/test_cuopt_direct.py index aef0a924468..402f5cc18d9 100644 --- a/pyomo/solvers/tests/checks/test_cuopt_direct.py +++ b/pyomo/solvers/tests/checks/test_cuopt_direct.py @@ -125,6 +125,25 @@ def test_infeasible_trivial_constraint(self): with pytest.raises(ValueError, match=r"Trivial constraint.*infeasible"): opt.solve(m, skip_trivial_constraints=True) + @unittest.skipIf(not cuopt_available, "The CuOpt solver is not available") + def test_unbounded_or_infeasible_status(self): + # An LP with no variable bounds and an unbounded objective triggers + # cuOpt's presolver to return UnboundedOrInfeasible (status 11), which + # the plugin maps to TerminationCondition.infeasibleOrUnbounded. + m = ConcreteModel() + m.x = Var() + m.y = Var() + m.obj = Objective(expr=m.x + m.y, sense=minimize) + + opt = SolverFactory('cuopt') + res = opt.solve(m, load_solutions=False) + + self.assertEqual( + res.solver.termination_condition, "infeasibleOrUnbounded" + ) + self.assertEqual(res.solver.status, "warning") + self.assertEqual(res.solution[0].status, "unsure") + @unittest.skipIf(not cuopt_available, "The CuOpt solver is not available") def test_nonlinear_constraint_rejected(self): m = ConcreteModel() From 326e154005808f912a7167dee59aeaabd3e5429e Mon Sep 17 00:00:00 2001 From: Bethany Nicholson Date: Fri, 17 Apr 2026 15:19:47 -0600 Subject: [PATCH 3/4] Run black --- pyomo/solvers/tests/checks/test_cuopt_direct.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/pyomo/solvers/tests/checks/test_cuopt_direct.py b/pyomo/solvers/tests/checks/test_cuopt_direct.py index 402f5cc18d9..baa13c560e8 100644 --- a/pyomo/solvers/tests/checks/test_cuopt_direct.py +++ b/pyomo/solvers/tests/checks/test_cuopt_direct.py @@ -138,9 +138,7 @@ def test_unbounded_or_infeasible_status(self): opt = SolverFactory('cuopt') res = opt.solve(m, load_solutions=False) - self.assertEqual( - res.solver.termination_condition, "infeasibleOrUnbounded" - ) + self.assertEqual(res.solver.termination_condition, "infeasibleOrUnbounded") self.assertEqual(res.solver.status, "warning") self.assertEqual(res.solution[0].status, "unsure") From e88cefbb1c6f8ec47d7c288f90e3323f19f68bcf Mon Sep 17 00:00:00 2001 From: Ramakrishna Prabhu Date: Wed, 29 Apr 2026 09:44:17 -0500 Subject: [PATCH 4/4] Skip UnboundedOrInfeasible test on cuOpt < 26.04 cuOpt status 11 (UnboundedOrInfeasible) is produced by the presolver introduced in cuOpt 26.04. On older cuOpt the LP in the test reaches the simplex/PDLP path and is reported as TerminationCondition.unbounded instead, failing the assertion. Guard the test with a runtime version check via CUOPTDirect._version. Addresses review feedback on PR #3916. --- .../solvers/tests/checks/test_cuopt_direct.py | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/pyomo/solvers/tests/checks/test_cuopt_direct.py b/pyomo/solvers/tests/checks/test_cuopt_direct.py index baa13c560e8..fa6620a603e 100644 --- a/pyomo/solvers/tests/checks/test_cuopt_direct.py +++ b/pyomo/solvers/tests/checks/test_cuopt_direct.py @@ -29,7 +29,18 @@ from pyomo.common.tee import capture_output from pyomo.common.tempfiles import TempfileManager import pyomo.common.unittest as unittest -from pyomo.solvers.plugins.solvers.cuopt_direct import cuopt_available +from pyomo.solvers.plugins.solvers.cuopt_direct import cuopt_available, CUOPTDirect + + +def _cuopt_at_least(*required): + """True iff cuOpt is available and at least the given (major, minor[, patch]) version.""" + if not cuopt_available: + return False + try: + version = tuple(int(p) for p in CUOPTDirect._version[: len(required)]) + except (AttributeError, TypeError, ValueError): + return False + return version >= required @unittest.pytest.mark.solver("cuopt") @@ -125,7 +136,10 @@ def test_infeasible_trivial_constraint(self): with pytest.raises(ValueError, match=r"Trivial constraint.*infeasible"): opt.solve(m, skip_trivial_constraints=True) - @unittest.skipIf(not cuopt_available, "The CuOpt solver is not available") + @unittest.skipUnless( + _cuopt_at_least(26, 4), + "cuOpt UnboundedOrInfeasible status (11) requires cuOpt 26.04 or later", + ) def test_unbounded_or_infeasible_status(self): # An LP with no variable bounds and an unbounded objective triggers # cuOpt's presolver to return UnboundedOrInfeasible (status 11), which