From 90adbf4cb3c123cd5847c247d37912167c8c7a5d Mon Sep 17 00:00:00 2001 From: Ishika Roy Date: Wed, 4 Jun 2025 11:25:22 -0700 Subject: [PATCH 01/18] 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 02/18] 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 03/18] 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 04/18] 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 05/18] 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 06/18] 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 07/18] 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 From 4bf03d500c96accbcd56a77cbf0dc28e163150e6 Mon Sep 17 00:00:00 2001 From: Ishika Roy Date: Sun, 12 Oct 2025 22:10:51 -0700 Subject: [PATCH 08/18] address review comments part 1 --- pyomo/solvers/plugins/solvers/cuopt_direct.py | 163 +++++++++++++----- 1 file changed, 120 insertions(+), 43 deletions(-) diff --git a/pyomo/solvers/plugins/solvers/cuopt_direct.py b/pyomo/solvers/plugins/solvers/cuopt_direct.py index 50aed02c5c1..e7d588d9eec 100644 --- a/pyomo/solvers/plugins/solvers/cuopt_direct.py +++ b/pyomo/solvers/plugins/solvers/cuopt_direct.py @@ -28,35 +28,99 @@ from pyomo.solvers.plugins.solvers.direct_or_persistent_solver import ( DirectOrPersistentSolver, ) -from pyomo.core.kernel.objective import minimize, maximize +from pyomo.common.enums 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 time -logger = logging.getLogger("pyomo.solvers") +logger = logging.getLogger(__name__) cuopt, cuopt_available = attempt_import("cuopt") +def toDict(model, json=False): + + #if not isinstance(model, parser_wrapper.DataModel): + # raise ValueError( + # "model must be a cuopt_mps_parser.parser_wrapper.Datamodel" + # ) + + # Replace numpy objects in generated data so that it is JSON serializable + def transform(data): + for key, value in data.items(): + if isinstance(value, dict): + transform(value) + elif isinstance(value, list): + if np.inf in data[key] or -np.inf in data[key]: + data[key] = [ + "inf" if x == np.inf else "ninf" if x == -np.inf else x + for x in data[key] + ] + + if json is True: + problem_data = { + "csr_constraint_matrix": { + "offsets": model.A_offsets.tolist(), + "indices": model.A_indices.tolist(), + "values": model.A_values.tolist(), + }, + "constraint_bounds": { + "bounds": model.b.tolist(), + "upper_bounds": model.constraint_upper_bounds.tolist(), + "lower_bounds": model.constraint_lower_bounds.tolist(), + "types": model.host_row_types.tolist(), + }, + "objective_data": { + "coefficients": model.c.tolist(), + "scalability_factor": model.objective_scaling_factor, + "offset": model.objective_offset, + }, + "variable_bounds": { + "upper_bounds": model.variable_upper_bounds.tolist(), + "lower_bounds": model.variable_lower_bounds.tolist(), + }, + "maximize": model.maximize, + "variable_types": model.variable_types.tolist(), + "variable_names": model.variable_names.tolist(), + } + transform(problem_data) + else: + problem_data = { + "csr_constraint_matrix": { + "offsets": model.A_offsets, + "indices": model.A_indices, + "values": model.A_values, + }, + "constraint_bounds": { + "bounds": model.b, + "upper_bounds": model.constraint_upper_bounds, + "lower_bounds": model.constraint_lower_bounds, + "types": model.host_row_types, + }, + "objective_data": { + "coefficients": model.c, + "scalability_factor": model.objective_scaling_factor, + "offset": model.objective_offset, + }, + "variable_bounds": { + "upper_bounds": model.variable_upper_bounds, + "lower_bounds": model.variable_lower_bounds, + }, + "maximize": model.maximize, + "variable_types": model.variable_types, + "variable_names": model.variable_names, + } + return problem_data @SolverFactory.register("cuopt", doc="Direct python interface to CUOPT") class CUOPTDirect(DirectSolver): def __init__(self, **kwds): kwds["type"] = "cuoptdirect" super(CUOPTDirect, self).__init__(**kwds) - 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 + self._version = cuopt.__version__.split('.') + self._python_api_exists = cuopt_available # Note: Undefined capabilities default to None self._capabilities.linear = True self._capabilities.integer = True @@ -68,12 +132,13 @@ def _apply_solver(self): if self._log_file: log_file = self._log_file t0 = time.time() - self.solution = cuopt.linear_programming.solver.Solve(self._solver_model) + settings = cuopt.linear_programming.solver_settings.SolverSettings() + self.solution = cuopt.linear_programming.solver.Solve(self._solver_model, settings) t1 = time.time() self._wallclock_time = t1 - t0 return Bunch(rc=None, log=None) - def _add_constraint(self, constraints): + def _add_constraints(self, constraints): c_lb, c_ub = [], [] matrix_data = [] matrix_indptr = [0] @@ -82,30 +147,32 @@ def _add_constraint(self, constraints): con_idx = 0 for con in constraints: if not con.active: - return None + continue if self._skip_trivial_constraints and is_fixed(con.body): - return None + continue if not con.has_lb() and not con.has_ub(): assert not con.equality continue # non-binding, so skip + lb, body, ub = con.to_bounded_expression(evaluate_bounds=True) + 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) + con_idx += 1 + repn = generate_standard_repn(body, quadratic=False) matrix_data.extend(repn.linear_coefs) matrix_indices.extend( - [self.var_name_dict[str(i)] for i in repn.linear_vars] + self.var_name_dict[str(i)] for i in repn.linear_vars ) self.referenced_vars.update(repn.linear_vars) matrix_indptr.append(len(matrix_data)) c_lb.append( - value(con.lower) - repn.constant if con.lower is not None else -np.inf + lb - repn.constant if lb is not None else -np.inf ) c_ub.append( - value(con.upper) - repn.constant if con.upper is not None else np.inf + ub - repn.constant if ub is not None else np.inf ) if len(matrix_data) == 0: @@ -121,23 +188,21 @@ def _add_constraint(self, constraints): 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): + def _add_variables(self, variables): # Map variable to index and get var bounds self.var_name_dict = {} v_lb, v_ub, v_type = [], [], [] for i, v in enumerate(variables): - if v.is_binary(): + lb, ub = v.bounds + if v.is_integer(): 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) + elif v.is_continuous(): + v_type.append("C") 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) + raise ValueError(f"Unallowable domain for variable {v.name}") + v_lb.append(lb if lb is not None else -np.inf) + v_ub.append(ub if 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 @@ -155,8 +220,7 @@ def _set_objective(self, objective): 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) + self._solver_model.set_maximize(objective.sense == maximize) def _set_instance(self, model, kwds={}): DirectOrPersistentSolver._set_instance(self, model, kwds) @@ -173,15 +237,16 @@ def _set_instance(self, model, kwds={}): "Have you installed the Python " "SDK for CUOPT?\n\n\t" + "Error message: {0}".format(e) ) + raise Exception(msg) self._add_block(model) def _add_block(self, block): - self._add_var( + self._add_variables( block.component_data_objects( ctype=Var, descend_into=True, active=True, sort=True ) ) - self._add_constraint( + self._add_constraints( block.component_data_objects( ctype=Constraint, descend_into=True, active=True, sort=True ) @@ -225,35 +290,47 @@ def _postsolve(self): prob_type = solution.problem_category - if status in [1]: + # 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 + + if status == 1: self.results.solver.status = SolverStatus.ok self.results.solver.termination_condition = TerminationCondition.optimal soln.status = SolutionStatus.optimal - elif status in [3]: + elif status == 3: self.results.solver.status = SolverStatus.warning self.results.solver.termination_condition = TerminationCondition.unbounded soln.status = SolutionStatus.unbounded - elif status in [8]: + elif status == 8: self.results.solver.status = SolverStatus.ok self.results.solver.termination_condition = TerminationCondition.feasible soln.status = SolutionStatus.feasible - elif status in [2]: + elif status == 2: self.results.solver.status = SolverStatus.warning self.results.solver.termination_condition = TerminationCondition.infeasible soln.status = SolutionStatus.infeasible - elif status in [4]: + elif status == 4: self.results.solver.status = SolverStatus.aborted self.results.solver.termination_condition = ( TerminationCondition.maxIterations ) soln.status = SolutionStatus.stoppedByLimit - elif status in [5]: + elif status == 5: self.results.solver.status = SolverStatus.aborted self.results.solver.termination_condition = ( TerminationCondition.maxTimeLimit ) soln.status = SolutionStatus.stoppedByLimit - elif status in [7]: + elif status == 7: self.results.solver.status = SolverStatus.ok self.results.solver.termination_condition = TerminationCondition.other soln.status = SolutionStatus.other From 3f5c2af23060334bb7b138e48d6573caa2c2890e Mon Sep 17 00:00:00 2001 From: Ishika Roy Date: Fri, 17 Oct 2025 08:15:26 -0700 Subject: [PATCH 09/18] address review comments part 2 --- pyomo/solvers/plugins/solvers/cuopt_direct.py | 97 +++---------------- .../solvers/tests/checks/test_cuopt_direct.py | 7 +- 2 files changed, 13 insertions(+), 91 deletions(-) diff --git a/pyomo/solvers/plugins/solvers/cuopt_direct.py b/pyomo/solvers/plugins/solvers/cuopt_direct.py index e7d588d9eec..371da398085 100644 --- a/pyomo/solvers/plugins/solvers/cuopt_direct.py +++ b/pyomo/solvers/plugins/solvers/cuopt_direct.py @@ -24,6 +24,7 @@ from pyomo.core.expr.numvalue import value from pyomo.core.staleflag import StaleFlagManager from pyomo.repn import generate_standard_repn +from pyomo.repn.linear import LinearRepnVisitor from pyomo.solvers.plugins.solvers.direct_solver import DirectSolver from pyomo.solvers.plugins.solvers.direct_or_persistent_solver import ( DirectOrPersistentSolver, @@ -40,79 +41,6 @@ cuopt, cuopt_available = attempt_import("cuopt") -def toDict(model, json=False): - - #if not isinstance(model, parser_wrapper.DataModel): - # raise ValueError( - # "model must be a cuopt_mps_parser.parser_wrapper.Datamodel" - # ) - - # Replace numpy objects in generated data so that it is JSON serializable - def transform(data): - for key, value in data.items(): - if isinstance(value, dict): - transform(value) - elif isinstance(value, list): - if np.inf in data[key] or -np.inf in data[key]: - data[key] = [ - "inf" if x == np.inf else "ninf" if x == -np.inf else x - for x in data[key] - ] - - if json is True: - problem_data = { - "csr_constraint_matrix": { - "offsets": model.A_offsets.tolist(), - "indices": model.A_indices.tolist(), - "values": model.A_values.tolist(), - }, - "constraint_bounds": { - "bounds": model.b.tolist(), - "upper_bounds": model.constraint_upper_bounds.tolist(), - "lower_bounds": model.constraint_lower_bounds.tolist(), - "types": model.host_row_types.tolist(), - }, - "objective_data": { - "coefficients": model.c.tolist(), - "scalability_factor": model.objective_scaling_factor, - "offset": model.objective_offset, - }, - "variable_bounds": { - "upper_bounds": model.variable_upper_bounds.tolist(), - "lower_bounds": model.variable_lower_bounds.tolist(), - }, - "maximize": model.maximize, - "variable_types": model.variable_types.tolist(), - "variable_names": model.variable_names.tolist(), - } - transform(problem_data) - else: - problem_data = { - "csr_constraint_matrix": { - "offsets": model.A_offsets, - "indices": model.A_indices, - "values": model.A_values, - }, - "constraint_bounds": { - "bounds": model.b, - "upper_bounds": model.constraint_upper_bounds, - "lower_bounds": model.constraint_lower_bounds, - "types": model.host_row_types, - }, - "objective_data": { - "coefficients": model.c, - "scalability_factor": model.objective_scaling_factor, - "offset": model.objective_offset, - }, - "variable_bounds": { - "upper_bounds": model.variable_upper_bounds, - "lower_bounds": model.variable_lower_bounds, - }, - "maximize": model.maximize, - "variable_types": model.variable_types, - "variable_names": model.variable_names, - } - return problem_data @SolverFactory.register("cuopt", doc="Direct python interface to CUOPT") class CUOPTDirect(DirectSolver): @@ -161,6 +89,8 @@ def _add_constraints(self, constraints): conname = self._symbol_map.getSymbol(con, self._labeler) self._pyomo_con_to_solver_con_map[con] = con_idx con_idx += 1 + repn = LinearRepnVisitor({}, {}, {}, None).walk_expression(body) + print(dir(repn)) repn = generate_standard_repn(body, quadratic=False) matrix_data.extend(repn.linear_coefs) matrix_indices.extend( @@ -230,8 +160,7 @@ def _set_instance(self, model, kwds={}): try: self._solver_model = cuopt.linear_programming.DataModel() - except Exception: - e = sys.exc_info()[1] + except Exception as e: msg = ( "Unable to create CUOPT model. " "Have you installed the Python " @@ -251,17 +180,13 @@ def _add_block(self, block): 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 - ): - obj_counter += 1 - if obj_counter > 1: - raise ValueError( - "Solver interface does not support multiple objectives." - ) - self._set_objective(obj) + objectives = list( + block.component_data_objects(Objective, descend_into=True, active=True) + ) + if len(objectives) > 1: + raise ValueError("Solver interface does not support multiple objectives.") + elif objectives: + self._set_objective(objectives[0]) def _postsolve(self): extract_duals = False diff --git a/pyomo/solvers/tests/checks/test_cuopt_direct.py b/pyomo/solvers/tests/checks/test_cuopt_direct.py index dffb2b1b551..ec6527ea90f 100644 --- a/pyomo/solvers/tests/checks/test_cuopt_direct.py +++ b/pyomo/solvers/tests/checks/test_cuopt_direct.py @@ -22,16 +22,13 @@ value, minimize, ) +from pyomo.common.dependencies import attempt_import 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 +cuopt, cuopt_available = attempt_import("cuopt") class CUOPTTests(unittest.TestCase): From c5a5f7b504e1922d41bf85d83828cb221f6c42a1 Mon Sep 17 00:00:00 2001 From: Ishika Roy Date: Fri, 17 Oct 2025 08:25:55 -0700 Subject: [PATCH 10/18] remove print --- pyomo/solvers/plugins/solvers/cuopt_direct.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/pyomo/solvers/plugins/solvers/cuopt_direct.py b/pyomo/solvers/plugins/solvers/cuopt_direct.py index 371da398085..954dba4b558 100644 --- a/pyomo/solvers/plugins/solvers/cuopt_direct.py +++ b/pyomo/solvers/plugins/solvers/cuopt_direct.py @@ -89,8 +89,6 @@ def _add_constraints(self, constraints): conname = self._symbol_map.getSymbol(con, self._labeler) self._pyomo_con_to_solver_con_map[con] = con_idx con_idx += 1 - repn = LinearRepnVisitor({}, {}, {}, None).walk_expression(body) - print(dir(repn)) repn = generate_standard_repn(body, quadratic=False) matrix_data.extend(repn.linear_coefs) matrix_indices.extend( From e433404f48572d307358c5feb1538957cf5f4068 Mon Sep 17 00:00:00 2001 From: Ishika Roy Date: Tue, 28 Oct 2025 08:18:28 -0700 Subject: [PATCH 11/18] address review part 3, black formatting --- pyomo/solvers/plugins/solvers/cuopt_direct.py | 47 ++++++++++++------- 1 file changed, 29 insertions(+), 18 deletions(-) diff --git a/pyomo/solvers/plugins/solvers/cuopt_direct.py b/pyomo/solvers/plugins/solvers/cuopt_direct.py index 954dba4b558..1ffc4ed886f 100644 --- a/pyomo/solvers/plugins/solvers/cuopt_direct.py +++ b/pyomo/solvers/plugins/solvers/cuopt_direct.py @@ -61,7 +61,9 @@ def _apply_solver(self): log_file = self._log_file t0 = time.time() settings = cuopt.linear_programming.solver_settings.SolverSettings() - self.solution = cuopt.linear_programming.solver.Solve(self._solver_model, settings) + self.solution = cuopt.linear_programming.solver.Solve( + self._solver_model, settings + ) t1 = time.time() self._wallclock_time = t1 - t0 return Bunch(rc=None, log=None) @@ -89,19 +91,15 @@ def _add_constraints(self, constraints): conname = self._symbol_map.getSymbol(con, self._labeler) self._pyomo_con_to_solver_con_map[con] = con_idx con_idx += 1 + repn = generate_standard_repn(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) self.referenced_vars.update(repn.linear_vars) + matrix_indptr.append(len(matrix_data)) - c_lb.append( - lb - repn.constant if lb is not None else -np.inf - ) - c_ub.append( - ub - repn.constant if ub is not None else np.inf - ) + c_lb.append(lb - repn.constant if lb is not None else -np.inf) + c_ub.append(ub - repn.constant if ub is not None else np.inf) if len(matrix_data) == 0: matrix_data = [0] @@ -211,7 +209,7 @@ def _postsolve(self): self.results.solver.name = "CUOPT" self.results.solver.wallclock_time = self._wallclock_time - prob_type = solution.problem_category + is_mip = solution.get_problem_category() # Termination Status # 0 - CUOPT_TERIMINATION_STATUS_NO_TERMINATION @@ -269,11 +267,24 @@ def _postsolve(self): 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 + if is_mip: + try: + ObjBound = solution.get_milp_stats()["solution_bound"] + ObjVal = solution.get_primal_objective() + if self._solver_model.maximize: + self.results.problem.upper_bound = ObjBound + self.results.problem.lower_bound = ObjVal + else: + self.results.problem.upper_bound = ObjVal + self.results.problem.lower_bound = ObjBound + except Exception as e: + pass + else: + 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 con_map = self._pyomo_con_to_solver_con_map @@ -281,9 +292,9 @@ def _postsolve(self): primal_solution = solution.get_primal_solution().tolist() reduced_costs = None dual_solution = None - if extract_reduced_costs and solution.get_problem_category() == 0: + if extract_reduced_costs and not is_mip: reduced_costs = solution.get_reduced_cost() - if extract_duals and solution.get_problem_category() == 0: + if extract_duals and not is_mip: dual_solution = solution.get_dual_solution() if self._save_results: From fb5ba6e0b1f2ba90af15bed4271e45f5825d994d Mon Sep 17 00:00:00 2001 From: Ishika Roy Date: Wed, 5 Nov 2025 21:20:02 -0800 Subject: [PATCH 12/18] move version resolution --- pyomo/solvers/plugins/solvers/cuopt_direct.py | 15 +++++++++++++-- pyomo/solvers/tests/checks/test_cuopt_direct.py | 4 ++-- 2 files changed, 15 insertions(+), 4 deletions(-) diff --git a/pyomo/solvers/plugins/solvers/cuopt_direct.py b/pyomo/solvers/plugins/solvers/cuopt_direct.py index 1ffc4ed886f..424f96d177c 100644 --- a/pyomo/solvers/plugins/solvers/cuopt_direct.py +++ b/pyomo/solvers/plugins/solvers/cuopt_direct.py @@ -1,3 +1,4 @@ + # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects @@ -39,7 +40,18 @@ logger = logging.getLogger(__name__) -cuopt, cuopt_available = attempt_import("cuopt") +def _get_cuopt_version(cuopt, avail): + if not avail: + return + CUOPTDirect._version = cuopt.__version__.split('.') + CUOPTDirect._name = "cuOpt %s.%s%s" % CUOPTDirect._version + + +cuopt, cuopt_available = attempt_import( + "cuopt", + catch_exceptions=(Exception,), + callback = _get_cuopt_version, +) @SolverFactory.register("cuopt", doc="Direct python interface to CUOPT") @@ -47,7 +59,6 @@ class CUOPTDirect(DirectSolver): def __init__(self, **kwds): kwds["type"] = "cuoptdirect" super(CUOPTDirect, self).__init__(**kwds) - self._version = cuopt.__version__.split('.') self._python_api_exists = cuopt_available # Note: Undefined capabilities default to None self._capabilities.linear = True diff --git a/pyomo/solvers/tests/checks/test_cuopt_direct.py b/pyomo/solvers/tests/checks/test_cuopt_direct.py index ec6527ea90f..dd2dec87bdc 100644 --- a/pyomo/solvers/tests/checks/test_cuopt_direct.py +++ b/pyomo/solvers/tests/checks/test_cuopt_direct.py @@ -23,14 +23,14 @@ minimize, ) from pyomo.common.dependencies import attempt_import +from pyomo.opt import check_available_solvers 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 cuopt, cuopt_available = attempt_import("cuopt") - class CUOPTTests(unittest.TestCase): @unittest.skipIf(not cuopt_available, "The CuOpt solver is not available") def test_values_and_rc(self): From bdb88a0e48b4d30b2a5f584ae27134751ecc5b7d Mon Sep 17 00:00:00 2001 From: Ishika Roy Date: Thu, 6 Nov 2025 09:13:30 -0800 Subject: [PATCH 13/18] reformat --- pyomo/solvers/plugins/solvers/cuopt_direct.py | 5 +---- pyomo/solvers/tests/checks/test_cuopt_direct.py | 1 + 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/pyomo/solvers/plugins/solvers/cuopt_direct.py b/pyomo/solvers/plugins/solvers/cuopt_direct.py index 424f96d177c..dcd53dd510e 100644 --- a/pyomo/solvers/plugins/solvers/cuopt_direct.py +++ b/pyomo/solvers/plugins/solvers/cuopt_direct.py @@ -1,4 +1,3 @@ - # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects @@ -48,9 +47,7 @@ def _get_cuopt_version(cuopt, avail): cuopt, cuopt_available = attempt_import( - "cuopt", - catch_exceptions=(Exception,), - callback = _get_cuopt_version, + "cuopt", catch_exceptions=(Exception,), callback=_get_cuopt_version ) diff --git a/pyomo/solvers/tests/checks/test_cuopt_direct.py b/pyomo/solvers/tests/checks/test_cuopt_direct.py index dd2dec87bdc..648676bea66 100644 --- a/pyomo/solvers/tests/checks/test_cuopt_direct.py +++ b/pyomo/solvers/tests/checks/test_cuopt_direct.py @@ -31,6 +31,7 @@ cuopt, cuopt_available = attempt_import("cuopt") + class CUOPTTests(unittest.TestCase): @unittest.skipIf(not cuopt_available, "The CuOpt solver is not available") def test_values_and_rc(self): From 51a29bf5798b10ec9f977929a6c730bb31303304 Mon Sep 17 00:00:00 2001 From: John Siirola Date: Fri, 14 Nov 2025 13:14:40 -0700 Subject: [PATCH 14/18] DeferredImportCallbackLoader needs to expose get_resource_reader() --- pyomo/common/dependencies.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/pyomo/common/dependencies.py b/pyomo/common/dependencies.py index 9f3e54e1a4d..69883b1f1ae 100644 --- a/pyomo/common/dependencies.py +++ b/pyomo/common/dependencies.py @@ -486,6 +486,9 @@ def exec_module(self, module: ModuleType) -> None: def load_module(self, fullname) -> ModuleType: return self._loader.load_module(fullname) + def get_resource_reader(self, fullname): + return self._loader.get_resource_reader(fullname) + class DeferredImportCallbackFinder: """Custom Finder that will wrap the normal loader to trigger callbacks From f9627ee54579422852aafd24dac346034e6ff3cf Mon Sep 17 00:00:00 2001 From: John Siirola Date: Fri, 14 Nov 2025 13:15:06 -0700 Subject: [PATCH 15/18] fix parsing of cuopt version --- pyomo/solvers/plugins/solvers/cuopt_direct.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/pyomo/solvers/plugins/solvers/cuopt_direct.py b/pyomo/solvers/plugins/solvers/cuopt_direct.py index dcd53dd510e..c565f31f821 100644 --- a/pyomo/solvers/plugins/solvers/cuopt_direct.py +++ b/pyomo/solvers/plugins/solvers/cuopt_direct.py @@ -42,13 +42,11 @@ def _get_cuopt_version(cuopt, avail): if not avail: return - CUOPTDirect._version = cuopt.__version__.split('.') + CUOPTDirect._version = tuple(cuopt.__version__.split('.')) CUOPTDirect._name = "cuOpt %s.%s%s" % CUOPTDirect._version -cuopt, cuopt_available = attempt_import( - "cuopt", catch_exceptions=(Exception,), callback=_get_cuopt_version -) +cuopt, cuopt_available = attempt_import("cuopt", callback=_get_cuopt_version) @SolverFactory.register("cuopt", doc="Direct python interface to CUOPT") From 0e598ee7f0c5c67e75368853d299a06b0e6ef139 Mon Sep 17 00:00:00 2001 From: John Siirola Date: Fri, 14 Nov 2025 13:15:33 -0700 Subject: [PATCH 16/18] Don't perform redundant attempt_import('cuopt') --- pyomo/solvers/tests/checks/test_cuopt_direct.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/pyomo/solvers/tests/checks/test_cuopt_direct.py b/pyomo/solvers/tests/checks/test_cuopt_direct.py index 648676bea66..e77958a5d89 100644 --- a/pyomo/solvers/tests/checks/test_cuopt_direct.py +++ b/pyomo/solvers/tests/checks/test_cuopt_direct.py @@ -29,8 +29,6 @@ import pyomo.common.unittest as unittest from pyomo.solvers.plugins.solvers.cuopt_direct import cuopt_available -cuopt, cuopt_available = attempt_import("cuopt") - class CUOPTTests(unittest.TestCase): @unittest.skipIf(not cuopt_available, "The CuOpt solver is not available") From 134cba7b875a38bd7973eb224b0bcc1a6ea6f139 Mon Sep 17 00:00:00 2001 From: Ishika Roy Date: Sun, 16 Nov 2025 22:28:57 -0800 Subject: [PATCH 17/18] delete instance level version --- pyomo/solvers/plugins/solvers/cuopt_direct.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/pyomo/solvers/plugins/solvers/cuopt_direct.py b/pyomo/solvers/plugins/solvers/cuopt_direct.py index c565f31f821..e3ae501503b 100644 --- a/pyomo/solvers/plugins/solvers/cuopt_direct.py +++ b/pyomo/solvers/plugins/solvers/cuopt_direct.py @@ -40,6 +40,7 @@ def _get_cuopt_version(cuopt, avail): + print(cuopt.__version__.split('.'), avail) if not avail: return CUOPTDirect._version = tuple(cuopt.__version__.split('.')) @@ -59,6 +60,10 @@ def __init__(self, **kwds): self._capabilities.linear = True self._capabilities.integer = True self.referenced_vars = ComponentSet() + # remove the instance-level definition of the cuopt version: + # because the version comes from an imported module, only one + # version of cuopt is supported (and stored as a class attribute) + del self._version def _apply_solver(self): StaleFlagManager.mark_all_as_stale() From 767d96ad220046ad43b4ef01da1509d317e6cae8 Mon Sep 17 00:00:00 2001 From: Ishika Roy <41401566+Iroy30@users.noreply.github.com> Date: Mon, 17 Nov 2025 00:30:03 -0600 Subject: [PATCH 18/18] Update cuopt_direct.py --- pyomo/solvers/plugins/solvers/cuopt_direct.py | 1 - 1 file changed, 1 deletion(-) diff --git a/pyomo/solvers/plugins/solvers/cuopt_direct.py b/pyomo/solvers/plugins/solvers/cuopt_direct.py index e3ae501503b..bdd586d053b 100644 --- a/pyomo/solvers/plugins/solvers/cuopt_direct.py +++ b/pyomo/solvers/plugins/solvers/cuopt_direct.py @@ -40,7 +40,6 @@ def _get_cuopt_version(cuopt, avail): - print(cuopt.__version__.split('.'), avail) if not avail: return CUOPTDirect._version = tuple(cuopt.__version__.split('.'))