Skip to content

Commit 0d6e0a5

Browse files
authored
CuOpt Solver Integration (#839)
* add cuopt solver interface * fix mip * update constraint extraction and tests * incorporate review changes * Update cuopt_api.py * Delete pulp/tests/test_pulp_temp.py * run mypy * Update cuopt_api.py
1 parent a1ae913 commit 0d6e0a5

File tree

3 files changed

+288
-1
lines changed

3 files changed

+288
-1
lines changed

pulp/apis/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
from .sas_api import *
1414
from .scip_api import *
1515
from .xpress_api import *
16+
from .cuopt_api import *
1617

1718
_all_solvers: List[Type[LpSolver]] = [
1819
CYLP,
@@ -41,6 +42,7 @@
4142
COPT_CMD,
4243
SAS94,
4344
SASCAS,
45+
CUOPT,
4446
]
4547

4648
import json

pulp/apis/cuopt_api.py

Lines changed: 277 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,277 @@
1+
import ctypes
2+
import os
3+
import subprocess
4+
import sys
5+
import warnings
6+
from uuid import uuid4
7+
from ..constants import (
8+
LpBinary,
9+
LpConstraintEQ,
10+
LpConstraintGE,
11+
LpConstraintLE,
12+
LpContinuous,
13+
LpInteger,
14+
LpMaximize,
15+
LpMinimize,
16+
LpStatusInfeasible,
17+
LpStatusNotSolved,
18+
LpStatusOptimal,
19+
LpStatusUnbounded,
20+
LpStatusUndefined,
21+
)
22+
from .core import (
23+
LpSolver,
24+
LpSolver_CMD,
25+
PulpSolverError,
26+
clock,
27+
ctypesArrayFill,
28+
sparse,
29+
)
30+
31+
# Constraint Sense Converter
32+
sense_conv = {
33+
LpConstraintLE: "L",
34+
LpConstraintGE: "G",
35+
LpConstraintEQ: "E",
36+
}
37+
38+
39+
class CUOPT(LpSolver):
40+
"""
41+
The CUOPT Optimizer via its python interface
42+
"""
43+
44+
name = "CUOPT"
45+
46+
try:
47+
global cuopt
48+
import cuopt # type: ignore[import-not-found, import-untyped, unused-ignore]
49+
50+
global np
51+
import numpy as np # type: ignore[import-not-found, import-untyped, unused-ignore]
52+
except:
53+
54+
def available(self):
55+
"""True if the solver is available"""
56+
return False
57+
58+
def actualSolve(self, lp, callback=None):
59+
"""Solve a well formulated lp problem"""
60+
raise PulpSolverError("CUOPT: Not available")
61+
62+
else:
63+
64+
def __init__(
65+
self,
66+
mip=True,
67+
msg=True,
68+
timeLimit=None,
69+
gapRel=None,
70+
warmStart=False,
71+
logPath=None,
72+
**solverParams,
73+
):
74+
"""
75+
:param bool mip: if False, assume LP even if integer variables
76+
:param bool msg: if False, no log is shown
77+
:param float timeLimit: maximum time for solver (in seconds)
78+
:param float gapRel: relative gap tolerance for the solver to stop (in fraction)
79+
:param bool warmStart: if True, the solver will use the current value of variables as a start
80+
:param str logPath: path to the log file
81+
:param solverParams: solver setting paramters for cuopt
82+
"""
83+
84+
LpSolver.__init__(
85+
self,
86+
mip=mip,
87+
msg=msg,
88+
timeLimit=timeLimit,
89+
gapRel=gapRel,
90+
logPath=logPath,
91+
warmStart=warmStart,
92+
)
93+
94+
from cuopt.linear_programming import data_model # type: ignore[import-not-found, import-untyped, unused-ignore]
95+
96+
self.model = data_model.DataModel()
97+
self.var_list = None
98+
self.solver_params = solverParams
99+
100+
def findSolutionValues(self, lp, solution):
101+
solutionStatus = solution.get_termination_status()
102+
if self.msg:
103+
print("CUOPT status=", solution.get_termination_reason())
104+
105+
CuoptStatus = {
106+
0: LpStatusNotSolved, # No Termination
107+
1: LpStatusOptimal, # Optimal
108+
2: LpStatusInfeasible, # Infeasible
109+
3: LpStatusUnbounded, # Unbounded
110+
4: LpStatusNotSolved, # Iteration Limit
111+
5: LpStatusNotSolved, # Timelimit
112+
6: LpStatusNotSolved, # Numerical Error
113+
7: LpStatusNotSolved, # Primal Feasible
114+
8: LpStatusNotSolved, # Feasible Found
115+
9: LpStatusNotSolved, # Concurrent Limit
116+
}
117+
118+
lp.resolveOK = True
119+
for var in lp._variables:
120+
var.isModified = False
121+
122+
status = CuoptStatus.get(solutionStatus, LpStatusUndefined)
123+
lp.assignStatus(status)
124+
125+
values = solution.get_primal_solution()
126+
127+
for var, value in zip(lp._variables, values):
128+
var.varValue = value
129+
130+
if not solution.get_problem_category():
131+
# TODO: Compute Slack
132+
133+
redcosts = solution.get_reduced_cost()
134+
for var, value in zip(lp._variables, redcosts):
135+
var.dj = value
136+
137+
duals = solution.get_dual_solution()
138+
for constr, value in zip(lp.constraints.values(), duals):
139+
constr.pi = value
140+
141+
return status
142+
143+
def available(self):
144+
"""True if the solver is available"""
145+
return True
146+
147+
def callSolver(self, lp, callback=None):
148+
"""Solves the problem with CUOPT"""
149+
from cuopt.linear_programming import solver_settings, solver # type: ignore[import-not-found, import-untyped, unused-ignore]
150+
151+
self.solveTime = -clock()
152+
# TODO: Add callback
153+
log_file = self.optionsDict.get("logPath") or ""
154+
155+
settings = solver_settings.SolverSettings()
156+
settings.set_parameter("infeasibility_detection", True)
157+
settings.set_parameter("log_to_console", self.msg)
158+
if self.timeLimit:
159+
settings.set_parameter("time_limit", self.timeLimit)
160+
for key, value in self.solver_params.items():
161+
if key == "optimality_tolerance":
162+
settings.set_optimality_tolerance(value)
163+
gapRel = self.optionsDict.get("gapRel")
164+
if gapRel:
165+
settings.set_parameter("relative_gap_tolerance", gapRel)
166+
167+
solution = solver.Solve(lp.solverModel, settings, log_file)
168+
169+
self.solveTime += clock()
170+
return solution
171+
172+
def buildSolverModel(self, lp):
173+
"""
174+
Takes the pulp lp model and translates it into a COPT model
175+
"""
176+
lp.solverModel = self.model
177+
178+
if lp.sense == LpMaximize:
179+
lp.solverModel.set_maximize(True)
180+
181+
var_lb, var_ub, var_type, var_name = [], [], [], []
182+
obj_coeff = []
183+
var_dict = {}
184+
185+
for i, var in enumerate(lp.variables()):
186+
obj_coeff.append(lp.objective.get(var, 0.0))
187+
lowBound = var.lowBound
188+
if lowBound is None:
189+
lowBound = -np.inf
190+
upBound = var.upBound
191+
if upBound is None:
192+
upBound = np.inf
193+
varType = "C"
194+
if var.cat == LpInteger and self.mip:
195+
varType = "I"
196+
if var.cat == LpBinary and self.mip:
197+
varType = "I"
198+
lowBound = 0
199+
upBound = 1
200+
var_lb.append(lowBound)
201+
var_ub.append(upBound)
202+
var_type.append(varType)
203+
var_name.append(var.name)
204+
var_dict[var.name] = i
205+
var.solverVar = {
206+
var.name: {"lb": var_lb, "ub": var_ub, "type": var_type}
207+
}
208+
lp.solverModel.set_variable_lower_bounds(np.array(var_lb))
209+
lp.solverModel.set_variable_upper_bounds(np.array(var_ub))
210+
lp.solverModel.set_variable_types(np.array(var_type))
211+
lp.solverModel.set_variable_names(np.array(var_name))
212+
213+
rhs, sense = [], []
214+
matrix_data, matrix_indices, matrix_indptr = [], [], [0]
215+
216+
for name, constraint in lp.constraints.items():
217+
row_coeffs = []
218+
matrix_data.extend(list(constraint.values()))
219+
matrix_indices.extend([var_dict[v.name] for v in constraint.keys()])
220+
matrix_indptr.append(len(matrix_data))
221+
try:
222+
c_sense = sense_conv[constraint.sense]
223+
except:
224+
raise PulpSolverError("Detected an invalid constraint type")
225+
rhs.append(-constraint.constant)
226+
sense.append(c_sense)
227+
lp.solverModel.set_csr_constraint_matrix(
228+
np.array(matrix_data), np.array(matrix_indices), np.array(matrix_indptr)
229+
)
230+
lp.solverModel.set_constraint_bounds(np.array(rhs))
231+
lp.solverModel.set_row_types(np.array(sense))
232+
233+
lp.solverModel.set_objective_coefficients(np.array(obj_coeff))
234+
235+
def actualSolve(self, lp, callback=None):
236+
"""
237+
Solve a well formulated lp problem
238+
239+
creates a COPT model, variables and constraints and attaches
240+
them to the lp model which it then solves
241+
"""
242+
self.buildSolverModel(lp)
243+
solution = self.callSolver(lp, callback=callback)
244+
245+
solutionStatus = self.findSolutionValues(lp, solution)
246+
for var in lp._variables:
247+
var.modified = False
248+
for constraint in lp.constraints.values():
249+
constraint.modified = False
250+
return solutionStatus
251+
252+
def actualResolve(self, lp, callback=None):
253+
"""
254+
Solve a well formulated lp problem
255+
256+
uses the old solver and modifies the rhs of the modified constraints
257+
"""
258+
rhs = lp.solverModel.get_constraint_bounds()
259+
sense = lp.solverModel.get_row_types()
260+
261+
for i, name, constraint in enumerate(lp.constraints.items()):
262+
if constraint.modified:
263+
sense[i] = sense_conv[constraint.sense]
264+
rhs[i] = -constraint.constant
265+
constraint.solverConstraint[name]["bound"] = rhs[i]
266+
constraint.solverConstraint[name]["sense"] = sense[i]
267+
lp.solverModel.set_constraint_bounds(rhs)
268+
lp.solverModel.set_row_types(sense)
269+
270+
self.callSolver(lp, callback=callback)
271+
272+
solutionStatus = self.findSolutionValues(lp)
273+
for var in lp._variables:
274+
var.modified = False
275+
for constraint in lp.constraints.values():
276+
constraint.modified = False
277+
return solutionStatus

pulp/tests/test_pulp.py

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -100,7 +100,11 @@ class PuLPTest(unittest.TestCase):
100100
solveInst: Optional[Type[LpSolver]] = None
101101

102102
def setUp(self):
103-
self.solver = self.solveInst(msg=False)
103+
if self.solveInst == CUOPT:
104+
# cuOpt requires a user provided time limit for MIP problems
105+
self.solver = self.solveInst(msg=False, timeLimit=120)
106+
else:
107+
self.solver = self.solveInst(msg=False)
104108
if not self.solver.available():
105109
self.skipTest(f"solver {self.solveInst.name} not available")
106110

@@ -2233,6 +2237,10 @@ class COPTTest(BaseSolverTest.PuLPTest):
22332237
solveInst = COPT
22342238

22352239

2240+
class CUOPTTest(BaseSolverTest.PuLPTest):
2241+
solveInst = CUOPT
2242+
2243+
22362244
class SASTest:
22372245

22382246
def test_sas_with_option(self):

0 commit comments

Comments
 (0)