From 7cb1cf3655e46306cf902f07be13166b3c28006a Mon Sep 17 00:00:00 2001 From: Ramakrishna Prabhu Date: Thu, 16 Apr 2026 15:51:03 -0500 Subject: [PATCH 1/3] 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/3] 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/3] 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")