From 90adbf4cb3c123cd5847c247d37912167c8c7a5d Mon Sep 17 00:00:00 2001 From: Ishika Roy Date: Wed, 4 Jun 2025 11:25:22 -0700 Subject: [PATCH 1/7] add cuopt direct solver --- pyomo/solvers/plugins/solvers/__init__.py | 1 + pyomo/solvers/plugins/solvers/cuopt_direct.py | 258 ++++++++++++++++++ 2 files changed, 259 insertions(+) create mode 100644 pyomo/solvers/plugins/solvers/cuopt_direct.py diff --git a/pyomo/solvers/plugins/solvers/__init__.py b/pyomo/solvers/plugins/solvers/__init__.py index cf10af15186..2dc34cb2ef6 100644 --- a/pyomo/solvers/plugins/solvers/__init__.py +++ b/pyomo/solvers/plugins/solvers/__init__.py @@ -26,6 +26,7 @@ gurobi_persistent, cplex_direct, cplex_persistent, + cuopt_direct, GAMS, mosek_direct, mosek_persistent, diff --git a/pyomo/solvers/plugins/solvers/cuopt_direct.py b/pyomo/solvers/plugins/solvers/cuopt_direct.py new file mode 100644 index 00000000000..4e26b20938c --- /dev/null +++ b/pyomo/solvers/plugins/solvers/cuopt_direct.py @@ -0,0 +1,258 @@ +# ___________________________________________________________________________ +# +# 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 logging +import re +import sys + +from pyomo.common.collections import ComponentSet, ComponentMap, Bunch +from pyomo.common.dependencies import attempt_import +from pyomo.core.base import Suffix, Var, Constraint, SOSConstraint, Objective +from pyomo.common.errors import ApplicationError +from pyomo.common.tempfiles import TempfileManager +from pyomo.common.tee import capture_output +from pyomo.core.expr.numvalue import is_fixed +from pyomo.core.expr.numvalue import value +from pyomo.core.staleflag import StaleFlagManager +from pyomo.repn import generate_standard_repn +from pyomo.solvers.plugins.solvers.direct_solver import DirectSolver +from pyomo.solvers.plugins.solvers.direct_or_persistent_solver import ( + DirectOrPersistentSolver, +) +from pyomo.core.kernel.objective import minimize, maximize +from pyomo.opt.results.results_ import SolverResults +from pyomo.opt.results.solution import Solution, SolutionStatus +from pyomo.opt.results.solver import TerminationCondition, SolverStatus +from pyomo.opt.base import SolverFactory +from pyomo.core.base.suffix import Suffix +import numpy as np +import time + +logger = logging.getLogger('pyomo.solvers') + +cuopt, cuopt_available = attempt_import( + 'cuopt', + ) + +@SolverFactory.register('cuopt_direct', doc='Direct python interface to CUOPT') +class CUOPTDirect(DirectSolver): + def __init__(self, **kwds): + kwds['type'] = 'cuoptdirect' + super(CUOPTDirect, self).__init__(**kwds) + self._python_api_exists = True + + def _apply_solver(self): + StaleFlagManager.mark_all_as_stale() + log_file = None + if self._log_file: + log_file = self._log_file + t0 = time.time() + self.solution = cuopt.linear_programming.solver.Solve(self._solver_model) + t1 = time.time() + self._wallclock_time = t1 - t0 + return Bunch(rc=None, log=None) + + def _add_constraint(self, constraints): + c_lb, c_ub = [], [] + matrix_data, matrix_indptr, matrix_indices = [], [0], [] + for i, con in enumerate(constraints): + repn = generate_standard_repn(con.body, quadratic=False) + matrix_data.extend(repn.linear_coefs) + matrix_indices.extend([self.var_name_dict[str(i)] for i in repn.linear_vars]) + """for v, c in zip(con.body.linear_vars, con.body.linear_coefs): + matrix_data.append(value(c)) + matrix_indices.append(self.var_name_dict[str(v)])""" + matrix_indptr.append(len(matrix_data)) + c_lb.append(value(con.lower) if con.lower is not None else -np.inf) + c_ub.append(value(con.upper) if con.upper is not None else np.inf) + self._solver_model.set_csr_constraint_matrix(np.array(matrix_data), np.array(matrix_indices), np.array(matrix_indptr)) + self._solver_model.set_constraint_lower_bounds(np.array(c_lb)) + self._solver_model.set_constraint_upper_bounds(np.array(c_ub)) + + def _add_var(self, variables): + # Map vriable to index and get var bounds + var_type_dict = {"Integers": 'I', "Reals": 'C', "Binary": 'I'} # NonNegativeReals ? + self.var_name_dict = {} + v_lb, v_ub, v_type = [], [], [] + + for i, v in enumerate(variables): + v_type.append(var_type_dict[str(v.domain)]) + if v.domain == "Binary": + v_lb.append(0) + v_ub.append(1) + else: + v_lb.append(v.lb if v.lb is not None else -np.inf) + v_ub.append(v.ub if v.ub is not None else np.inf) + self.var_name_dict[str(v)] = i + self._pyomo_var_to_ndx_map[v] = self._ndx_count + self._ndx_count += 1 + + self._solver_model.set_variable_lower_bounds(np.array(v_lb)) + self._solver_model.set_variable_upper_bounds(np.array(v_ub)) + self._solver_model.set_variable_types(np.array(v_type)) + self._solver_model.set_variable_names(np.array(list(self.var_name_dict.keys()))) + + def _set_objective(self, objective): + repn = generate_standard_repn(objective.expr, quadratic=False) + obj_coeffs = [0] * len(self.var_name_dict) + for i, coeff in enumerate(repn.linear_coefs): + obj_coeffs[self.var_name_dict[str(repn.linear_vars[i])]] = coeff + self._solver_model.set_objective_coefficients(np.array(obj_coeffs)) + if objective.sense == maximize: + self._solver_model.set_maximize(True) + + def _set_instance(self, model, kwds={}): + DirectOrPersistentSolver._set_instance(self, model, kwds) + self.var_name_dict = None + self._pyomo_var_to_ndx_map = ComponentMap() + self._ndx_count = 0 + + try: + self._solver_model = cuopt.linear_programming.DataModel() + except Exception: + e = sys.exc_info()[1] + msg = ( + "Unable to create CUOPT model. " + "Have you installed the Python " + "SDK for CUOPT?\n\n\t" + "Error message: {0}".format(e) + ) + self._add_block(model) + + def _add_block(self, block): + self._add_var(block.component_data_objects( + ctype=Var, descend_into=True, active=True, sort=True) + ) + + for sub_block in block.block_data_objects(descend_into=True, active=True): + self._add_constraint(sub_block.component_data_objects( + ctype=Constraint, descend_into=False, active=True, sort=True) + ) + obj_counter = 0 + for obj in sub_block.component_data_objects( + ctype=Objective, descend_into=False, active=True + ): + obj_counter += 1 + if obj_counter > 1: + raise ValueError( + "Solver interface does not support multiple objectives." + ) + self._set_objective(obj) + + def _postsolve(self): + extract_duals = False + extract_slacks = False + extract_reduced_costs = False + for suffix in self._suffixes: + flag = False + if re.match(suffix, "rc"): + extract_reduced_costs = True + flag = True + if not flag: + raise RuntimeError( + "***The cuopt_direct solver plugin cannot extract solution suffix=" + + suffix + ) + + solution = self.solution + status = solution.get_termination_status() + self.results = SolverResults() + soln = Solution() + self.results.solver.name = "CUOPT" + self.results.solver.wallclock_time = self._wallclock_time + + prob_type = solution.problem_category + + if status in [1]: + self.results.solver.status = SolverStatus.ok + self.results.solver.termination_condition = TerminationCondition.optimal + soln.status = SolutionStatus.optimal + elif status in [3]: + self.results.solver.status = SolverStatus.warning + self.results.solver.termination_condition = TerminationCondition.unbounded + soln.status = SolutionStatus.unbounded + elif status in [8]: + self.results.solver.status = SolverStatus.ok + self.results.solver.termination_condition = TerminationCondition.feasible + soln.status = SolutionStatus.feasible + elif status in [2]: + self.results.solver.status = SolverStatus.warning + self.results.solver.termination_condition = TerminationCondition.infeasible + soln.status = SolutionStatus.infeasible + elif status in [4]: + self.results.solver.status = SolverStatus.aborted + self.results.solver.termination_condition = ( + TerminationCondition.maxIterations + ) + soln.status = SolutionStatus.stoppedByLimit + elif status in [5]: + self.results.solver.status = SolverStatus.aborted + self.results.solver.termination_condition = ( + TerminationCondition.maxTimeLimit + ) + soln.status = SolutionStatus.stoppedByLimit + elif status in [7]: + self.results.solver.status = SolverStatus.ok + self.results.solver.termination_condition = ( + TerminationCondition.other + ) + soln.status = SolutionStatus.other + else: + self.results.solver.status = SolverStatus.error + self.results.solver.termination_condition = TerminationCondition.error + soln.status = SolutionStatus.error + + if self._solver_model.maximize: + self.results.problem.sense = maximize + else: + self.results.problem.sense = minimize + + self.results.problem.upper_bound = None + self.results.problem.lower_bound = None + try: + self.results.problem.upper_bound = solution.get_primal_objective() + self.results.problem.lower_bound = solution.get_primal_objective() + except Exception as e: + pass + + var_map = self._pyomo_var_to_ndx_map + primal_solution = solution.get_primal_solution().tolist() + for i, pyomo_var in enumerate(var_map.keys()): + pyomo_var.set_value(primal_solution[i], skip_validation=True) + + if extract_reduced_costs: + self._load_rc() + + self.results.solution.insert(soln) + return DirectOrPersistentSolver._postsolve(self) + + def warm_start_capable(self): + return False + + def _load_rc(self, vars_to_load=None): + if not hasattr(self._pyomo_model, 'rc'): + self._pyomo_model.rc = Suffix(direction=Suffix.IMPORT) + rc = self._pyomo_model.rc + var_map = self._pyomo_var_to_ndx_map + if vars_to_load is None: + vars_to_load = var_map.keys() + reduced_costs = self.solution.get_reduced_costs() + for pyomo_var in vars_to_load: + rc[pyomo_var] = reduced_costs[var_map[pyomo_var]] + + def load_rc(self, vars_to_load): + """ + Load the reduced costs into the 'rc' suffix. The 'rc' suffix must live on the parent model. + + Parameters + ---------- + vars_to_load: list of Var + """ + self._load_rc(vars_to_load) From b4b1502c8f87c49f4028eb1a521f6b7529ee089a Mon Sep 17 00:00:00 2001 From: Ishika Roy Date: Wed, 2 Jul 2025 05:29:09 -0700 Subject: [PATCH 2/7] add tests with lp amd milp capabilities --- pyomo/solvers/plugins/solvers/cuopt_direct.py | 3 +++ pyomo/solvers/tests/solvers.py | 17 +++++++++++++++++ 2 files changed, 20 insertions(+) diff --git a/pyomo/solvers/plugins/solvers/cuopt_direct.py b/pyomo/solvers/plugins/solvers/cuopt_direct.py index 4e26b20938c..cda905b025a 100644 --- a/pyomo/solvers/plugins/solvers/cuopt_direct.py +++ b/pyomo/solvers/plugins/solvers/cuopt_direct.py @@ -48,6 +48,9 @@ def __init__(self, **kwds): kwds['type'] = 'cuoptdirect' super(CUOPTDirect, self).__init__(**kwds) self._python_api_exists = True + # Note: Undefined capabilities default to None + self._capabilities.linear = True + self._capabilities.integer = True def _apply_solver(self): StaleFlagManager.mark_all_as_stale() diff --git a/pyomo/solvers/tests/solvers.py b/pyomo/solvers/tests/solvers.py index 60a3f36857b..004051f03fd 100644 --- a/pyomo/solvers/tests/solvers.py +++ b/pyomo/solvers/tests/solvers.py @@ -426,6 +426,23 @@ def test_solver_cases(*args): logging.disable(logging.NOTSET) + # + # CUOPT + # + _cuopt_capabilities = set( + [ + 'linear', + 'integer', + ] + ) + + _test_solver_cases['cuopt', 'python'] = initialize( + name='cuopt_direct', + io='python', + capabilities=_cuopt_capabilities, + import_suffixes=['rc'], + ) + # # Error Checks # From e2e91599638d89c5183cab96274628b7e0e8bcb8 Mon Sep 17 00:00:00 2001 From: Ishika Roy <41401566+Iroy30@users.noreply.github.com> Date: Wed, 9 Jul 2025 13:15:38 -0500 Subject: [PATCH 3/7] Update pyomo/solvers/plugins/solvers/cuopt_direct.py Co-authored-by: Miranda Mundt <55767766+mrmundt@users.noreply.github.com> --- pyomo/solvers/plugins/solvers/cuopt_direct.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyomo/solvers/plugins/solvers/cuopt_direct.py b/pyomo/solvers/plugins/solvers/cuopt_direct.py index cda905b025a..45761b9fadc 100644 --- a/pyomo/solvers/plugins/solvers/cuopt_direct.py +++ b/pyomo/solvers/plugins/solvers/cuopt_direct.py @@ -81,7 +81,7 @@ def _add_constraint(self, constraints): self._solver_model.set_constraint_upper_bounds(np.array(c_ub)) def _add_var(self, variables): - # Map vriable to index and get var bounds + # Map variable to index and get var bounds var_type_dict = {"Integers": 'I', "Reals": 'C', "Binary": 'I'} # NonNegativeReals ? self.var_name_dict = {} v_lb, v_ub, v_type = [], [], [] From 61e19bf52fe8b2798f3b1030c28c8fe041e3e101 Mon Sep 17 00:00:00 2001 From: Ishika Roy Date: Wed, 9 Jul 2025 17:15:45 -0700 Subject: [PATCH 4/7] formatting --- pyomo/solvers/plugins/solvers/cuopt_direct.py | 43 ++++++++++++------- 1 file changed, 27 insertions(+), 16 deletions(-) diff --git a/pyomo/solvers/plugins/solvers/cuopt_direct.py b/pyomo/solvers/plugins/solvers/cuopt_direct.py index cda905b025a..a705a3cae2b 100644 --- a/pyomo/solvers/plugins/solvers/cuopt_direct.py +++ b/pyomo/solvers/plugins/solvers/cuopt_direct.py @@ -36,16 +36,17 @@ import numpy as np import time -logger = logging.getLogger('pyomo.solvers') +logger = logging.getLogger("pyomo.solvers") cuopt, cuopt_available = attempt_import( - 'cuopt', - ) + "cuopt", +) + -@SolverFactory.register('cuopt_direct', doc='Direct python interface to CUOPT') +@SolverFactory.register("cuopt_direct", doc="Direct python interface to CUOPT") class CUOPTDirect(DirectSolver): def __init__(self, **kwds): - kwds['type'] = 'cuoptdirect' + kwds["type"] = "cuoptdirect" super(CUOPTDirect, self).__init__(**kwds) self._python_api_exists = True # Note: Undefined capabilities default to None @@ -69,20 +70,28 @@ def _add_constraint(self, constraints): for i, con in enumerate(constraints): repn = generate_standard_repn(con.body, quadratic=False) matrix_data.extend(repn.linear_coefs) - matrix_indices.extend([self.var_name_dict[str(i)] for i in repn.linear_vars]) + matrix_indices.extend( + [self.var_name_dict[str(i)] for i in repn.linear_vars] + ) """for v, c in zip(con.body.linear_vars, con.body.linear_coefs): matrix_data.append(value(c)) matrix_indices.append(self.var_name_dict[str(v)])""" matrix_indptr.append(len(matrix_data)) c_lb.append(value(con.lower) if con.lower is not None else -np.inf) c_ub.append(value(con.upper) if con.upper is not None else np.inf) - self._solver_model.set_csr_constraint_matrix(np.array(matrix_data), np.array(matrix_indices), np.array(matrix_indptr)) + self._solver_model.set_csr_constraint_matrix( + np.array(matrix_data), np.array(matrix_indices), np.array(matrix_indptr) + ) self._solver_model.set_constraint_lower_bounds(np.array(c_lb)) self._solver_model.set_constraint_upper_bounds(np.array(c_ub)) def _add_var(self, variables): # Map vriable to index and get var bounds - var_type_dict = {"Integers": 'I', "Reals": 'C', "Binary": 'I'} # NonNegativeReals ? + var_type_dict = { + "Integers": "I", + "Reals": "C", + "Binary": "I", + } # NonNegativeReals ? self.var_name_dict = {} v_lb, v_ub, v_type = [], [], [] @@ -130,13 +139,17 @@ def _set_instance(self, model, kwds={}): self._add_block(model) def _add_block(self, block): - self._add_var(block.component_data_objects( - ctype=Var, descend_into=True, active=True, sort=True) + self._add_var( + block.component_data_objects( + ctype=Var, descend_into=True, active=True, sort=True + ) ) for sub_block in block.block_data_objects(descend_into=True, active=True): - self._add_constraint(sub_block.component_data_objects( - ctype=Constraint, descend_into=False, active=True, sort=True) + self._add_constraint( + sub_block.component_data_objects( + ctype=Constraint, descend_into=False, active=True, sort=True + ) ) obj_counter = 0 for obj in sub_block.component_data_objects( @@ -203,9 +216,7 @@ def _postsolve(self): soln.status = SolutionStatus.stoppedByLimit elif status in [7]: self.results.solver.status = SolverStatus.ok - self.results.solver.termination_condition = ( - TerminationCondition.other - ) + self.results.solver.termination_condition = TerminationCondition.other soln.status = SolutionStatus.other else: self.results.solver.status = SolverStatus.error @@ -240,7 +251,7 @@ def warm_start_capable(self): return False def _load_rc(self, vars_to_load=None): - if not hasattr(self._pyomo_model, 'rc'): + if not hasattr(self._pyomo_model, "rc"): self._pyomo_model.rc = Suffix(direction=Suffix.IMPORT) rc = self._pyomo_model.rc var_map = self._pyomo_var_to_ndx_map From 5d8ec53a9f9c02f82c723263607303276fd9d8d3 Mon Sep 17 00:00:00 2001 From: Miranda Mundt <55767766+mrmundt@users.noreply.github.com> Date: Thu, 10 Jul 2025 08:57:30 -0600 Subject: [PATCH 5/7] Solver name incorrect - change to cuopt_direct --- pyomo/solvers/tests/solvers.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyomo/solvers/tests/solvers.py b/pyomo/solvers/tests/solvers.py index ab4aa3ff6ed..28ee9cdd5e8 100644 --- a/pyomo/solvers/tests/solvers.py +++ b/pyomo/solvers/tests/solvers.py @@ -429,7 +429,7 @@ def test_solver_cases(*args): ] ) - _test_solver_cases['cuopt', 'python'] = initialize( + _test_solver_cases['cuopt_direct', 'python'] = initialize( name='cuopt_direct', io='python', capabilities=_cuopt_capabilities, From 17d8cadbc2e2273dae71401489a2b7deb017a17c Mon Sep 17 00:00:00 2001 From: iroy Date: Mon, 11 Aug 2025 22:14:34 -0500 Subject: [PATCH 6/7] add cuopt tests and fix CI fails --- pyomo/solvers/plugins/solvers/cuopt_direct.py | 147 ++++++++++++++---- .../solvers/tests/checks/test_cuopt_direct.py | 82 ++++++++++ pyomo/solvers/tests/solvers.py | 13 +- pyomo/solvers/tests/testcases.py | 13 ++ 4 files changed, 212 insertions(+), 43 deletions(-) create mode 100644 pyomo/solvers/tests/checks/test_cuopt_direct.py diff --git a/pyomo/solvers/plugins/solvers/cuopt_direct.py b/pyomo/solvers/plugins/solvers/cuopt_direct.py index 944011315bf..3a6d96e1d87 100644 --- a/pyomo/solvers/plugins/solvers/cuopt_direct.py +++ b/pyomo/solvers/plugins/solvers/cuopt_direct.py @@ -38,20 +38,21 @@ logger = logging.getLogger("pyomo.solvers") -cuopt, cuopt_available = attempt_import( - "cuopt", -) + +cuopt, cuopt_available = attempt_import("cuopt") -@SolverFactory.register("cuopt_direct", doc="Direct python interface to CUOPT") +@SolverFactory.register("cuopt", doc="Direct python interface to CUOPT") class CUOPTDirect(DirectSolver): def __init__(self, **kwds): kwds["type"] = "cuoptdirect" super(CUOPTDirect, self).__init__(**kwds) + self._version = tuple(int(k) for k in cuopt.__version__.split('.')) self._python_api_exists = True # Note: Undefined capabilities default to None self._capabilities.linear = True self._capabilities.integer = True + self.referenced_vars = ComponentSet() def _apply_solver(self): StaleFlagManager.mark_all_as_stale() @@ -66,19 +67,46 @@ def _apply_solver(self): def _add_constraint(self, constraints): c_lb, c_ub = [], [] - matrix_data, matrix_indptr, matrix_indices = [], [0], [] - for i, con in enumerate(constraints): + matrix_data = [] + matrix_indptr = [0] + matrix_indices = [] + + con_idx = 0 + for con in constraints: + if not con.active: + return None + + if self._skip_trivial_constraints and is_fixed(con.body): + return None + + if not con.has_lb() and not con.has_ub(): + assert not con.equality + continue # non-binding, so skip + + conname = self._symbol_map.getSymbol(con, self._labeler) + self._pyomo_con_to_solver_con_map[con] = con_idx + con_idx += 0 repn = generate_standard_repn(con.body, quadratic=False) matrix_data.extend(repn.linear_coefs) matrix_indices.extend( [self.var_name_dict[str(i)] for i in repn.linear_vars] ) - """for v, c in zip(con.body.linear_vars, con.body.linear_coefs): - matrix_data.append(value(c)) - matrix_indices.append(self.var_name_dict[str(v)])""" + self.referenced_vars.update(repn.linear_vars) matrix_indptr.append(len(matrix_data)) - c_lb.append(value(con.lower) if con.lower is not None else -np.inf) - c_ub.append(value(con.upper) if con.upper is not None else np.inf) + c_lb.append( + value(con.lower) - repn.constant if con.lower is not None else -np.inf + ) + c_ub.append( + value(con.upper) - repn.constant if con.upper is not None else np.inf + ) + + if len(matrix_data) == 0: + matrix_data = [0] + matrix_indices = [0] + matrix_indptr = [0, 1] + c_lb = [0] + c_ub = [0] + self._solver_model.set_csr_constraint_matrix( np.array(matrix_data), np.array(matrix_indices), np.array(matrix_indptr) ) @@ -86,22 +114,20 @@ def _add_constraint(self, constraints): self._solver_model.set_constraint_upper_bounds(np.array(c_ub)) def _add_var(self, variables): - # Map vriable to index and get var bounds - var_type_dict = { - "Integers": "I", - "Reals": "C", - "Binary": "I", - } # NonNegativeReals ? - + # Map variable to index and get var bounds self.var_name_dict = {} v_lb, v_ub, v_type = [], [], [] for i, v in enumerate(variables): - v_type.append(var_type_dict[str(v.domain)]) - if v.domain == "Binary": - v_lb.append(0) - v_ub.append(1) + if v.is_binary(): + v_type.append("I") + v_lb.append(v.lb if v.lb is not None else 0) + v_ub.append(v.ub if v.ub is not None else 1) else: + if v.is_integer(): + v_type.append("I") + else: + v_type.append("C") v_lb.append(v.lb if v.lb is not None else -np.inf) v_ub.append(v.ub if v.ub is not None else np.inf) self.var_name_dict[str(v)] = i @@ -115,6 +141,8 @@ def _add_var(self, variables): def _set_objective(self, objective): repn = generate_standard_repn(objective.expr, quadratic=False) + self.referenced_vars.update(repn.linear_vars) + obj_coeffs = [0] * len(self.var_name_dict) for i, coeff in enumerate(repn.linear_coefs): obj_coeffs[self.var_name_dict[str(repn.linear_vars[i])]] = coeff @@ -145,13 +173,12 @@ def _add_block(self, block): ctype=Var, descend_into=True, active=True, sort=True ) ) - - for sub_block in block.block_data_objects(descend_into=True, active=True): - self._add_constraint( - sub_block.component_data_objects( - ctype=Constraint, descend_into=False, active=True, sort=True - ) + self._add_constraint( + block.component_data_objects( + ctype=Constraint, descend_into=True, active=True, sort=True ) + ) + for sub_block in block.block_data_objects(descend_into=True, active=True): obj_counter = 0 for obj in sub_block.component_data_objects( ctype=Objective, descend_into=False, active=True @@ -169,6 +196,9 @@ def _postsolve(self): extract_reduced_costs = False for suffix in self._suffixes: flag = False + if re.match(suffix, "dual"): + extract_duals = True + flag = True if re.match(suffix, "rc"): extract_reduced_costs = True flag = True @@ -238,12 +268,40 @@ def _postsolve(self): pass var_map = self._pyomo_var_to_ndx_map + con_map = self._pyomo_con_to_solver_con_map + primal_solution = solution.get_primal_solution().tolist() - for i, pyomo_var in enumerate(var_map.keys()): - pyomo_var.set_value(primal_solution[i], skip_validation=True) + reduced_costs = None + dual_solution = None + if extract_reduced_costs and solution.get_problem_category() == 0: + reduced_costs = solution.get_reduced_cost() + if extract_duals and solution.get_problem_category() == 0: + dual_solution = solution.get_dual_solution() + + if self._save_results: + soln_variables = soln.variable + soln_constraints = soln.constraint + for i, pyomo_var in enumerate(var_map.keys()): + if len(primal_solution) > 0 and pyomo_var in self.referenced_vars: + name = self._symbol_map.getSymbol(pyomo_var, self._labeler) + soln_variables[name] = {"Value": primal_solution[i]} + if reduced_costs is not None: + soln_variables[name]["Rc"] = reduced_costs[i] + for i, pyomo_con in enumerate(con_map.keys()): + if dual_solution is not None: + con_name = self._symbol_map.getSymbol(pyomo_con, self._labeler) + soln_constraints[con_name] = {"Dual": dual_solution[i]} + + elif self._load_solutions: + if len(primal_solution) > 0: + for i, pyomo_var in enumerate(var_map.keys()): + pyomo_var.set_value(primal_solution[i], skip_validation=True) + + if reduced_costs is not None: + self._load_rc() - if extract_reduced_costs: - self._load_rc() + if dual_solution is not None: + self._load_duals() self.results.solution.insert(soln) return DirectOrPersistentSolver._postsolve(self) @@ -258,11 +316,11 @@ def _load_rc(self, vars_to_load=None): var_map = self._pyomo_var_to_ndx_map if vars_to_load is None: vars_to_load = var_map.keys() - reduced_costs = self.solution.get_reduced_costs() + reduced_costs = self.solution.get_reduced_cost() for pyomo_var in vars_to_load: rc[pyomo_var] = reduced_costs[var_map[pyomo_var]] - def load_rc(self, vars_to_load): + def load_rc(self, vars_to_load=None): """ Load the reduced costs into the 'rc' suffix. The 'rc' suffix must live on the parent model. @@ -271,3 +329,24 @@ def load_rc(self, vars_to_load): vars_to_load: list of Var """ self._load_rc(vars_to_load) + + def _load_duals(self, cons_to_load=None): + if not hasattr(self._pyomo_model, 'dual'): + self._pyomo_model.dual = Suffix(direction=Suffix.IMPORT) + dual = self._pyomo_model.dual + con_map = self._pyomo_con_to_solver_con_map + if cons_to_load is None: + cons_to_load = con_map.keys() + dual_solution = self.solution.get_reduced_cost() + for pyomo_con in cons_to_load: + dual[pyomo_con] = dual_solution[con_map[pyomo_con]] + + def load_duals(self, cons_to_load=None): + """ + Load the duals into the 'dual' suffix. The 'dual' suffix must live on the parent model. + + Parameters + ---------- + cons_to_load: list of Constraint + """ + self._load_duals(cons_to_load) diff --git a/pyomo/solvers/tests/checks/test_cuopt_direct.py b/pyomo/solvers/tests/checks/test_cuopt_direct.py new file mode 100644 index 00000000000..dffb2b1b551 --- /dev/null +++ b/pyomo/solvers/tests/checks/test_cuopt_direct.py @@ -0,0 +1,82 @@ +# ___________________________________________________________________________ +# +# 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 os +from pyomo.environ import ( + SolverFactory, + ConcreteModel, + Block, + Var, + Constraint, + Objective, + NonNegativeReals, + Suffix, + value, + minimize, +) +from pyomo.common.tee import capture_output +from pyomo.common.tempfiles import TempfileManager +import pyomo.common.unittest as unittest + +try: + import cuopt + + cuopt_available = True +except: + cuopt_available = False + + +class CUOPTTests(unittest.TestCase): + @unittest.skipIf(not cuopt_available, "The CuOpt solver is not available") + def test_values_and_rc(self): + m = ConcreteModel() + + m.dual = Suffix(direction=Suffix.IMPORT) + m.rc = Suffix(direction=Suffix.IMPORT) + + m.x = Var(domain=NonNegativeReals) + m.top_con = Constraint(expr=m.x >= 0) + + m.b1 = Block() + m.b1.y = Var(domain=NonNegativeReals) + m.b1.con1 = Constraint(expr=m.x + m.b1.y <= 10) + + m.b1.subb = Block() + m.b1.subb.z = Var(domain=NonNegativeReals) + m.b1.subb.con2 = Constraint(expr=2 * m.b1.y + m.b1.subb.z >= 8) + + m.b2 = Block() + m.b2.w = Var(domain=NonNegativeReals) + m.b2.con3 = Constraint(expr=m.b1.subb.z - m.b2.w == 2) + + # Minimize cost = 1*x + 2*y + 3*z + 0.5*w + m.obj = Objective( + expr=1.0 * m.x + 2.0 * m.b1.y + 3.0 * m.b1.subb.z + 0.5 * m.b2.w, + sense=minimize, + ) + + opt = SolverFactory('cuopt') + res = opt.solve(m) + + expected_rc = [1.0, 0.0, 0.0, 2.5] + expected_val = [0.0, 3.0, 2.0, 0.0] + expected_dual = [0.0, 0.0, 1.0, 2.0] + + for i, v in enumerate([m.x, m.b1.y, m.b1.subb.z, m.b2.w]): + self.assertAlmostEqual(m.rc[v], expected_rc[i], places=5) + self.assertAlmostEqual(value(v), expected_val[i], places=5) + + for i, c in enumerate([m.top_con, m.b1.con1, m.b1.subb.con2, m.b2.con3]): + self.assertAlmostEqual(m.dual[c], expected_dual[i], places=5) + + +if __name__ == "__main__": + unittest.main() diff --git a/pyomo/solvers/tests/solvers.py b/pyomo/solvers/tests/solvers.py index 28ee9cdd5e8..bbb26abf079 100644 --- a/pyomo/solvers/tests/solvers.py +++ b/pyomo/solvers/tests/solvers.py @@ -422,18 +422,13 @@ def test_solver_cases(*args): # # CUOPT # - _cuopt_capabilities = set( - [ - 'linear', - 'integer', - ] - ) + _cuopt_capabilities = set(['linear', 'integer']) - _test_solver_cases['cuopt_direct', 'python'] = initialize( - name='cuopt_direct', + _test_solver_cases['cuopt', 'python'] = initialize( + name='cuopt', io='python', capabilities=_cuopt_capabilities, - import_suffixes=['rc'], + import_suffixes=['rc', 'dual'], ) # diff --git a/pyomo/solvers/tests/testcases.py b/pyomo/solvers/tests/testcases.py index 696936ddf05..73198f03553 100644 --- a/pyomo/solvers/tests/testcases.py +++ b/pyomo/solvers/tests/testcases.py @@ -311,6 +311,19 @@ 'Unbounded MILP detection not operational in Knitro, fixed in 15.0', ) +# +# CUOPT +# +SkipTests['cuopt', 'python', 'LP_duals_maximize'] = ( + lambda v: True, + "cuopt fails on RC for maximization", +) +for _test in ('MILP_unbounded', 'MILP_unbounded_kernel'): + SkipTests['cuopt', 'python', _test] = ( + lambda v: True, + "cuopt does not differentiate between unbounded and infeasible status", + ) + def generate_scenarios(arg=None): """ From af1d91e547b0bf182e79cf15c240cbb644c0d7b6 Mon Sep 17 00:00:00 2001 From: iroy Date: Tue, 12 Aug 2025 18:41:08 -0500 Subject: [PATCH 7/7] update import --- pyomo/solvers/plugins/solvers/cuopt_direct.py | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/pyomo/solvers/plugins/solvers/cuopt_direct.py b/pyomo/solvers/plugins/solvers/cuopt_direct.py index 3a6d96e1d87..50aed02c5c1 100644 --- a/pyomo/solvers/plugins/solvers/cuopt_direct.py +++ b/pyomo/solvers/plugins/solvers/cuopt_direct.py @@ -15,6 +15,7 @@ from pyomo.common.collections import ComponentSet, ComponentMap, Bunch from pyomo.common.dependencies import attempt_import +from pyomo.common.dependencies import numpy as np from pyomo.core.base import Suffix, Var, Constraint, SOSConstraint, Objective from pyomo.common.errors import ApplicationError from pyomo.common.tempfiles import TempfileManager @@ -33,7 +34,6 @@ from pyomo.opt.results.solver import TerminationCondition, SolverStatus from pyomo.opt.base import SolverFactory from pyomo.core.base.suffix import Suffix -import numpy as np import time logger = logging.getLogger("pyomo.solvers") @@ -47,8 +47,16 @@ class CUOPTDirect(DirectSolver): def __init__(self, **kwds): kwds["type"] = "cuoptdirect" super(CUOPTDirect, self).__init__(**kwds) - self._version = tuple(int(k) for k in cuopt.__version__.split('.')) - self._python_api_exists = True + try: + import cuopt + + self._version = tuple(int(k) for k in cuopt.__version__.split('.')) + self._python_api_exists = True + except ImportError: + self._python_api_exists = False + except Exception as e: + print("Import of cuopt failed - cuopt message=" + str(e) + "\n") + self._python_api_exists = False # Note: Undefined capabilities default to None self._capabilities.linear = True self._capabilities.integer = True