Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
90adbf4
add cuopt direct solver
Iroy30 Jun 4, 2025
b4b1502
add tests with lp amd milp capabilities
Iroy30 Jul 2, 2025
6f64094
Merge remote-tracking branch 'origin/main'
Iroy30 Jul 2, 2025
d9e42ad
Merge branch 'main' into add_cuopt_direct_solver_plugin
Iroy30 Jul 9, 2025
e2e9159
Update pyomo/solvers/plugins/solvers/cuopt_direct.py
Iroy30 Jul 9, 2025
d4a5d07
Merge remote-tracking branch 'origin'
Iroy30 Jul 9, 2025
61e19bf
formatting
Iroy30 Jul 10, 2025
432d8c0
pyomo/solvers/plugins/solvers/cuopt_direct.py
Iroy30 Jul 10, 2025
5d8ec53
Solver name incorrect - change to cuopt_direct
mrmundt Jul 10, 2025
66b9aa8
Merge branch 'main' into add_cuopt_direct_solver_plugin
Iroy30 Aug 6, 2025
46c5466
Merge branch 'main' into add_cuopt_direct_solver_plugin
Iroy30 Aug 7, 2025
1a61334
Merge branch 'main' into add_cuopt_direct_solver_plugin
Iroy30 Aug 11, 2025
17d8cad
add cuopt tests and fix CI fails
Iroy30 Aug 12, 2025
f064d64
Merge remote-tracking branch 'origin/add_cuopt_direct_solver_plugin' …
Iroy30 Aug 12, 2025
b978841
Merge branch 'main' into add_cuopt_direct_solver_plugin
Iroy30 Aug 12, 2025
af1d91e
update import
Iroy30 Aug 12, 2025
645abe3
Merge branch 'main' into add_cuopt_direct_solver_plugin
Iroy30 Aug 12, 2025
caea3bd
Merge remote-tracking branch 'origin/add_cuopt_direct_solver_plugin' …
Iroy30 Aug 12, 2025
5540913
Merge branch 'main' into add_cuopt_direct_solver_plugin
Iroy30 Aug 14, 2025
1b65acd
Merge branch 'main' into add_cuopt_direct_solver_plugin
Iroy30 Aug 20, 2025
19236fe
Merge branch 'main' into add_cuopt_direct_solver_plugin
Iroy30 Aug 20, 2025
9670f41
Merge branch 'main' into add_cuopt_direct_solver_plugin
Iroy30 Aug 21, 2025
6cfa3e4
Merge branch 'main' into add_cuopt_direct_solver_plugin
Iroy30 Aug 26, 2025
4c87a9e
Merge branch 'main' into add_cuopt_direct_solver_plugin
mrmundt Sep 29, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions pyomo/solvers/plugins/solvers/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
gurobi_persistent,
cplex_direct,
cplex_persistent,
cuopt_direct,
GAMS,
mosek_direct,
mosek_persistent,
Expand Down
360 changes: 360 additions & 0 deletions pyomo/solvers/plugins/solvers/cuopt_direct.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,360 @@
# ___________________________________________________________________________
#
# 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

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please consider alphabetizing the imports

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
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
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please do not import from kernel.

Suggested change
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
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You import Suffix twice - once here and once on line 19

import time

logger = logging.getLogger("pyomo.solvers")
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please use the logger more liberally!

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Also, we are really trying to move to a paradigm where we log using

logger = logging.getLogger(__name__)



cuopt, cuopt_available = attempt_import("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)
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")
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We generally log/raise rather than print. Not a rule, but generally a suggestion.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I will argue that it is a rule: as Pyomo is a library and not an application (with the exception of the pyomoscript), we should 100% avoid print() (outside the pyomo script)

Comment on lines +50 to +58
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This entire block of code is unnecessary and can run afoul of the cuopt imported at the module scope using attempt_import. I think the entire thing can be replaced with

self._version = cuopt.__version__.split('.')
self._python_api_exists = cuopt_available

(Note that cuopt.__version__ will trigger the import and cause cuopt_available to be resolved to a bool)

self._python_api_exists = False
# 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()
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()
Comment on lines +70 to +72
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is fine, but just so you're aware, we have this lovely little utility called TicTocTimer that you may want to consider using: https://pyomo.readthedocs.io/en/latest/api/pyomo.common.timing.TicTocTimer.html

self._wallclock_time = t1 - t0
return Bunch(rc=None, log=None)

def _add_constraint(self, constraints):
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is confusing: the method implies it is for a single constraint, but the implementation and usage below are for a generator of constraints. I would recommend renaming to make it clear that you are expecting multiple constraints.

Suggested change
def _add_constraint(self, constraints):
def _add_constraints(self, constraints):

c_lb, c_ub = [], []
matrix_data = []
matrix_indptr = [0]
matrix_indices = []

con_idx = 0
for con in constraints:
if not con.active:
return None
Comment on lines +84 to +85
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If there are multiple constraints, one that is active and all others that aren't, and this hits an inactive one first, wouldn't that cause the loop to immediately exit / it wouldn't get to the active constraint? I think you probably want continue instead of return None.


if self._skip_trivial_constraints and is_fixed(con.body):
return None
Comment on lines +87 to +88
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You should check if the trivial constraint is actually feasible


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
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This doesn't seem to actually be incrementing.

repn = generate_standard_repn(con.body, quadratic=False)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would strongly recommend using the pyomo.repn.linear.LinearRepnVisitor and not generate_standard_repn (it has known limitations and is being replaced by the other visitors in pyomo.repn).

matrix_data.extend(repn.linear_coefs)
matrix_indices.extend(
[self.var_name_dict[str(i)] for i in repn.linear_vars]
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
[self.var_name_dict[str(i)] for i in repn.linear_vars]
self.var_name_dict[str(i)] for i in repn.linear_vars

(the explicit formation of the list is unnecessary and adds unnecessary overhead)

)
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
)
c_ub.append(
value(con.upper) - repn.constant if con.upper is not None else np.inf
)
Comment on lines +104 to +109
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is doing a bunch of unnecessary work

  • it processes the constraint multiple times (each call to .body, .lower, and .upper)
  • it casts the bounds into Pyomo expressions (even if they started as constants) -- that you then have to reevaluate [and it does it twice: once in the if and once for the return value]

I would recommend at the beginning of the loop doing:

for con in constraints:
    lb, body, ub = con.to_bounded_expression(evaluate_bounds=True)


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)
)
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):
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Similar to _add_constraint above, I would recommend renaming:

Suggested change
def _add_var(self, variables):
def _add_vars(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):
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Note that each call to .lb and .ub has to process and resolve the effective variable bounds (the tighter of the user-set bounds and the current domain). It would be best to evaluate them in one shot and store the result:

lb, ub = v.bounds

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)
Comment on lines +132 to +133
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

lb and ub can never be None for Binary domains... Further, since cuopt doesn't need to be told about binary domains, you should just skip this test / code (is_binary() == True implies is_integer() == True)

else:
if v.is_integer():
v_type.append("I")
else:
v_type.append("C")
Comment on lines +135 to +138
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It would be good form to verify that is_continuous() is True... Pyomo is very flexible in what it allows users to declare, and the domain could be something other than a simple bounded interval.

Suggested change
if v.is_integer():
v_type.append("I")
else:
v_type.append("C")
if v.is_integer():
v_type.append("I")
elif v.is_continuous():
v_type.append("C")
else:
raise ValueError(f"Unallowable domain for variable {v.name}")

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)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Again, I would recommend switching to pyomo.repn.linear.LinearRepnVisitor

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
self._solver_model.set_objective_coefficients(np.array(obj_coeffs))
if objective.sense == maximize:
self._solver_model.set_maximize(True)
Comment on lines +158 to +159
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This relies on the cuopt default being minimize. A slightly more robust solution might be:

Suggested change
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)
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)
)
Comment on lines +169 to +175
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is never raised or logged, so a user will never see this message.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Worse, the user will get a strange exception later ... probably from _add_var.

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_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
):
obj_counter += 1
if obj_counter > 1:
raise ValueError(
"Solver interface does not support multiple objectives."
)
self._set_objective(obj)
Comment on lines +189 to +199
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This could be made simpler / more efficient:

Suggested change
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
extract_slacks = False
extract_reduced_costs = False
for suffix in self._suffixes:
flag = False
if re.match(suffix, "dual"):
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I could very well be wrong, but isn't this backwards? I thought it worked like:

# re.match(pattern, string), e.g.,
re.match("Hello", some_string)

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This logic is fragile, and is being reworked in the contrib.solver effort. It really doesn't need to use re at all and can just use ==.

That said, this logic was copied from other direct solvers (I saw it in CPLEX.py and GUROBI.py). It would be good to rework this, but given that it is consistent with the (questionable) behavior in other solvers, I will not complain if we leave it as is.

extract_duals = True
flag = True
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
)
Comment on lines +213 to +217
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It might be better to log loudly than to raise an error here (no strong feelings either way, just wanted to offer an alternative option).


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]:
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This should not create lists / use in:

Suggested change
if status in [1]:
if status == 1:

(here and all the subsequent tests)

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]:
Comment on lines +228 to +256
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There are a lot of "magic numbers" here - what is 1? What is 3? What is 7? Can you give them names or at least add comments/explanation?

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()
Comment on lines +273 to +274
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This implies convergence to a MIP gap of 0... I think this needs to be something like (assuming get_dual_objective() is a real thing):

Suggested change
self.results.problem.upper_bound = solution.get_primal_objective()
self.results.problem.lower_bound = solution.get_primal_objective()
if self._solver_model.maximize:
self.results.problem.upper_bound = solution.get_dual_objective()
self.results.problem.lower_bound = solution.get_primal_objective()
else:
self.results.problem.upper_bound = solution.get_primal_objective()
self.results.problem.lower_bound = solution.get_dual_objective()

except Exception as e:
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()
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 dual_solution is not None:
self._load_duals()

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_cost()
for pyomo_var in vars_to_load:
rc[pyomo_var] = reduced_costs[var_map[pyomo_var]]

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.

Parameters
----------
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)
Loading
Loading