Skip to content

add cuopt direct solver #3620

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 9 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
273 changes: 273 additions & 0 deletions pyomo/solvers/plugins/solvers/cuopt_direct.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,273 @@
# ___________________________________________________________________________
#
# 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
# Note: Undefined capabilities default to None
self._capabilities.linear = True
self._capabilities.integer = 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()
Comment on lines +61 to +63
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):
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 ?
Copy link
Contributor

Choose a reason for hiding this comment

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

I am fairly certain this is necessary. I got the tests running on our test machine, and I keep getting these errors:

__________________________________________________________ Test_LP_block.test_cuopt_direct_python_nonsymbolic_labels ___________________________________________________________

self = <pyomo.solvers.tests.checks.test_pickle.Test_LP_block testMethod=test_cuopt_direct_python_nonsymbolic_labels>

    def return_test(self):
>       return pickle_test(self)

pyomo/solvers/tests/checks/test_pickle.py:141: 
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ 
pyomo/solvers/tests/checks/test_pickle.py:48: in pickle_test
    opt, status = model_class.solve(
pyomo/solvers/tests/models/base.py:123: in solve
    results = opt.solve(
pyomo/solvers/plugins/solvers/direct_solver.py:130: in solve
    self._presolve(*args, **kwds)
pyomo/solvers/plugins/solvers/direct_solver.py:68: in _presolve
    self._set_instance(model, kwds)
pyomo/solvers/plugins/solvers/cuopt_direct.py:140: in _set_instance
    self._add_block(model)
pyomo/solvers/plugins/solvers/cuopt_direct.py:143: in _add_block
    self._add_var(
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ 

self = <pyomo.solvers.plugins.solvers.cuopt_direct.CUOPTDirect object at 0x7f91e6fc5050>, variables = <generator object BlockData.component_data_objects at 0x7f91ec180a60>

    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)])
E           KeyError: 'NonNegativeReals'

pyomo/solvers/plugins/solvers/cuopt_direct.py:100: KeyError

I am also seeing errors like this:

pyomo/solvers/tests/checks/test_no_solution_behavior.py:92: 
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ 
pyomo/solvers/tests/checks/test_no_solution_behavior.py:61: in failed_solve_test
    opt, results = model_class.solve(
pyomo/solvers/tests/models/base.py:123: in solve
    results = opt.solve(
pyomo/solvers/plugins/solvers/direct_solver.py:130: in solve
    self._presolve(*args, **kwds)
pyomo/solvers/plugins/solvers/direct_solver.py:68: in _presolve
    self._set_instance(model, kwds)
pyomo/solvers/plugins/solvers/cuopt_direct.py:140: in _set_instance
    self._add_block(model)
pyomo/solvers/plugins/solvers/cuopt_direct.py:143: in _add_block
    self._add_var(
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ 

self = <pyomo.solvers.plugins.solvers.cuopt_direct.CUOPTDirect object at 0x7f05bc563e10>
variables = <generator object _component_data_objects at 0x7f043a8d6890>

    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)])
E           AttributeError: property 'domain' of 'variable' object has no getter

pyomo/solvers/plugins/solvers/cuopt_direct.py:100: AttributeError

Plus some other errors:


self = <pyomo.solvers.tests.checks.test_pickle.Test_LP_duals_maximize testMethod=test_cuopt_direct_python_nonsymbolic_labels>

    def return_test(self):
>       return pickle_test(self)

pyomo/solvers/tests/checks/test_pickle.py:141: 
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ 
pyomo/solvers/tests/checks/test_pickle.py:48: in pickle_test
    opt, status = model_class.solve(
pyomo/solvers/tests/models/base.py:123: in solve
    results = opt.solve(
pyomo/solvers/plugins/solvers/direct_solver.py:168: in solve
    result = self._postsolve()
pyomo/solvers/plugins/solvers/cuopt_direct.py:246: in _postsolve
    self._load_rc()
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ 

self = <pyomo.solvers.plugins.solvers.cuopt_direct.CUOPTDirect object at 0x7f909a5f4dd0>
vars_to_load = KeysView(<pyomo.common.collections.component_map.ComponentMap object at 0x7f9099137af0>)

    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()
E       AttributeError: 'Solution' object has no attribute 'get_reduced_costs'

pyomo/solvers/plugins/solvers/cuopt_direct.py:261: AttributeError
________________________________________________________ Test_LP_unbounded.test_cuopt_direct_python_nonsymbolic_labels _________________________________________________________

self = <pyomo.solvers.tests.checks.test_pickle.Test_LP_unbounded testMethod=test_cuopt_direct_python_nonsymbolic_labels>

    def return_test(self):
>       return pickle_test(self)

pyomo/solvers/tests/checks/test_pickle.py:141: 
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ 
pyomo/solvers/tests/checks/test_pickle.py:48: in pickle_test
    opt, status = model_class.solve(
pyomo/solvers/tests/models/base.py:123: in solve
    results = opt.solve(
pyomo/solvers/plugins/solvers/direct_solver.py:168: in solve
    result = self._postsolve()
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ 

self = <pyomo.solvers.plugins.solvers.cuopt_direct.CUOPTDirect object at 0x7f909927cbd0>

    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)
E           IndexError: list index out of range

pyomo/solvers/plugins/solvers/cuopt_direct.py:243: IndexError
----------------------------------------------------------------------------- Captured stdout call -----------------------------------------------------------------------------
Setting parameter log_file to 
Error in solve_lp: {"CUOPT_ERROR_TYPE": "ValidationError", "msg": "A_offsets must be set before calling the solver."}


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)
17 changes: 17 additions & 0 deletions pyomo/solvers/tests/solvers.py
Original file line number Diff line number Diff line change
Expand Up @@ -419,6 +419,23 @@ def test_solver_cases(*args):

logging.disable(logging.NOTSET)

#
# CUOPT
#
_cuopt_capabilities = set(
[
'linear',
'integer',
]
)

_test_solver_cases['cuopt_direct', 'python'] = initialize(
name='cuopt_direct',
io='python',
capabilities=_cuopt_capabilities,
import_suffixes=['rc'],
)

#
# Error Checks
#
Expand Down