Skip to content

Commit 07bd272

Browse files
Transurgeonwujianjackclaude
authored
Adds COPT solver interface (original PR in #126) (#130)
* Add COPT support * Add copt to pytest * Fix code style and copt interface * Fix copt tests * Restore original test_interfaces.py The matrix interface tests were accidentally overwritten with NLP solver tests. This restores the original unit tests for NumPy and SciPy sparse matrix interfaces. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> --------- Co-authored-by: wujian <wujian@shanshu.ai> Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
1 parent 449b7c1 commit 07bd272

File tree

4 files changed

+199
-20
lines changed

4 files changed

+199
-20
lines changed

cvxpy/reductions/solvers/defines.py

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,9 @@
4646
from cvxpy.reductions.solvers.conic_solvers.scs_conif import SCS as SCS_con
4747
from cvxpy.reductions.solvers.conic_solvers.sdpa_conif import SDPA as SDPA_con
4848
from cvxpy.reductions.solvers.conic_solvers.xpress_conif import XPRESS as XPRESS_con
49+
50+
# NLP interfaces
51+
from cvxpy.reductions.solvers.nlp_solvers.copt_nlpif import COPT as COPT_nlp
4952
from cvxpy.reductions.solvers.nlp_solvers.ipopt_nlpif import IPOPT as IPOPT_nlp
5053
from cvxpy.reductions.solvers.nlp_solvers.uno_nlpif import UNO as UNO_nlp
5154

@@ -85,7 +88,7 @@
8588
MPAX_qp(),
8689
KNITRO_qp(),
8790
]
88-
solver_nlp_intf = [IPOPT_nlp(), UNO_nlp()]
91+
solver_nlp_intf = [IPOPT_nlp(), UNO_nlp(), COPT_nlp()]
8992

9093
SOLVER_MAP_CONIC = {solver.name(): solver for solver in solver_conic_intf}
9194
SOLVER_MAP_QP = {solver.name(): solver for solver in solver_qp_intf}
@@ -138,7 +141,7 @@
138141
s.MPAX,
139142
s.KNITRO,
140143
]
141-
NLP_SOLVERS = [s.IPOPT, s.UNO]
144+
NLP_SOLVERS = [s.IPOPT, s.UNO, s.COPT]
142145
DISREGARD_CLARABEL_SDP_SUPPORT_FOR_DEFAULT_RESOLUTION = True
143146
MI_SOLVERS = [
144147
s.GLPK_MI,

cvxpy/reductions/solvers/nlp_solvers/copt_nlpif.py

Lines changed: 175 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -26,16 +26,19 @@ class COPT(NLPsolver):
2626
"""
2727
NLP interface for the COPT solver.
2828
"""
29+
# Map between COPT status and CVXPY status
2930
STATUS_MAP = {
30-
# Success cases
31-
0: s.OPTIMAL, # Solve_Succeeded
32-
1: s.OPTIMAL_INACCURATE, # Solved_To_Acceptable_Level
33-
6: s.OPTIMAL, # Feasible_Point_Found
34-
35-
# Infeasibility/Unboundedness
36-
2: s.INFEASIBLE, # Infeasible_Problem_Detected
37-
4: s.UNBOUNDED, # Diverging_Iterates
38-
}
31+
1: s.OPTIMAL, # optimal
32+
2: s.INFEASIBLE, # infeasible
33+
3: s.UNBOUNDED, # unbounded
34+
4: s.INF_OR_UNB, # infeasible or unbounded
35+
5: s.SOLVER_ERROR, # numerical
36+
6: s.USER_LIMIT, # node limit
37+
7: s.OPTIMAL_INACCURATE, # imprecise
38+
8: s.USER_LIMIT, # time out
39+
9: s.SOLVER_ERROR, # unfinished
40+
10: s.USER_LIMIT # interrupted
41+
}
3942

4043
def name(self):
4144
"""
@@ -47,16 +50,18 @@ def import_solver(self):
4750
"""
4851
Imports the solver.
4952
"""
50-
import cyipopt # noqa F401
53+
import coptpy # noqa F401
5154

5255
def invert(self, solution, inverse_data):
5356
"""
5457
Returns the solution to the original problem given the inverse_data.
5558
"""
56-
attr = {}
59+
attr = {
60+
s.NUM_ITERS: solution.get('num_iters'),
61+
s.SOLVE_TIME: solution.get('solve_time_real'),
62+
}
63+
5764
status = self.STATUS_MAP[solution['status']]
58-
attr[s.NUM_ITERS] = solution['iterations']
59-
6065
if status in s.SOLUTION_PRESENT:
6166
primal_val = solution['obj_val']
6267
opt_val = primal_val + inverse_data.offset
@@ -106,7 +111,163 @@ def solve_via_data(self, data, warm_start: bool, verbose: bool, solver_opts, sol
106111
tuple
107112
(status, optimal value, primal, equality dual, inequality dual)
108113
"""
109-
raise NotImplementedError("COPT NLP interface is not yet implemented.")
114+
import coptpy as copt
115+
116+
class COPTNlpCallbackCVXPY(copt.NlpCallbackBase):
117+
def __init__(self, oracles, m):
118+
super().__init__()
119+
self._oracles = oracles
120+
self._m = m
121+
122+
def EvalObj(self, xdata, outdata):
123+
x = copt.NdArray(xdata)
124+
outval = copt.NdArray(outdata)
125+
126+
x_np = x.tonumpy()
127+
outval_np = self._oracles.objective(x_np)
128+
129+
outval[:] = outval_np
130+
return 0
131+
132+
def EvalGrad(self, xdata, outdata):
133+
x = copt.NdArray(xdata)
134+
outval = copt.NdArray(outdata)
135+
136+
x_np = x.tonumpy()
137+
outval_np = self._oracles.gradient(x_np)
138+
139+
outval[:] = np.asarray(outval_np).flatten()
140+
return 0
141+
142+
def EvalCon(self, xdata, outdata):
143+
if self._m > 0:
144+
x = copt.NdArray(xdata)
145+
outval = copt.NdArray(outdata)
146+
147+
x_np = x.tonumpy()
148+
outval_np = self._oracles.constraints(x_np)
149+
150+
outval[:] = np.asarray(outval_np).flatten()
151+
return 0
152+
153+
def EvalJac(self, xdata, outdata):
154+
if self._m > 0:
155+
x = copt.NdArray(xdata)
156+
outval = copt.NdArray(outdata)
157+
158+
x_np = x.tonumpy()
159+
outval_np = self._oracles.jacobian(x_np)
160+
161+
outval[:] = np.asarray(outval_np).flatten()
162+
return 0
163+
164+
def EvalHess(self, xdata, sigma, lambdata, outdata):
165+
x = copt.NdArray(xdata)
166+
lagrange = copt.NdArray(lambdata)
167+
outval = copt.NdArray(outdata)
168+
169+
x_np = x.tonumpy()
170+
lagrange_np = lagrange.tonumpy()
171+
outval_np = self._oracles.hessian(x_np, lagrange_np, sigma)
172+
173+
outval[:] = np.asarray(outval_np).flatten()
174+
return 0
175+
176+
# Create COPT environment and model
177+
envconfig = copt.EnvrConfig()
178+
if not verbose:
179+
envconfig.set('nobanner', '1')
180+
181+
env = copt.Envr(envconfig)
182+
model = env.createModel()
183+
184+
# Pass through verbosity
185+
model.setParam(copt.COPT.Param.Logging, verbose)
186+
187+
# Get oracles for function evaluation
188+
oracles = data['oracles']
189+
190+
# Get the NLP problem data
191+
x0 = data['x0']
192+
lb, ub = data['lb'].copy(), data['ub'].copy()
193+
cl, cu = data['cl'].copy(), data['cu'].copy()
194+
195+
lb[lb == -np.inf] = -copt.COPT.INFINITY
196+
ub[ub == +np.inf] = +copt.COPT.INFINITY
197+
cl[cl == -np.inf] = -copt.COPT.INFINITY
198+
cu[cu == +np.inf] = +copt.COPT.INFINITY
199+
200+
n = len(lb)
201+
m = len(cl)
202+
203+
cbtype = copt.COPT.EVALTYPE_OBJVAL | copt.COPT.EVALTYPE_CONSTRVAL | \
204+
copt.COPT.EVALTYPE_GRADIENT | copt.COPT.EVALTYPE_JACOBIAN | \
205+
copt.COPT.EVALTYPE_HESSIAN
206+
cbfunc = COPTNlpCallbackCVXPY(oracles, m)
207+
208+
if m > 0:
209+
jac_rows, jac_cols = oracles.jacobianstructure()
210+
nnz_jac = len(jac_rows)
211+
else:
212+
jac_rows = None
213+
jac_cols = None
214+
nnz_jac = 0
215+
216+
if n > 0:
217+
hess_rows, hess_cols = oracles.hessianstructure()
218+
nnz_hess = len(hess_rows)
219+
else:
220+
hess_rows = None
221+
hess_cols = None
222+
nnz_hess = 0
223+
224+
# Load NLP problem data
225+
model.loadNlData(n, # Number of variables
226+
m, # Number of constraints
227+
copt.COPT.MINIMIZE, # Objective sense
228+
copt.COPT.DENSETYPE_ROWMAJOR, None, # Dense objective gradient
229+
nnz_jac, jac_rows, jac_cols, # Sparse jacobian
230+
nnz_hess, hess_rows, hess_cols, # Sparse hessian
231+
lb, ub, # Variable bounds
232+
cl, cu, # Constraint bounds
233+
x0, # Starting point
234+
cbtype, cbfunc # Callback function
235+
)
236+
237+
# Set parameters
238+
for key, value in solver_opts.items():
239+
model.setParam(key, value)
240+
241+
# Solve problem
242+
model.solve()
243+
244+
# Get solution
245+
nlp_status = model.status
246+
nlp_hassol = model.haslpsol
247+
248+
if nlp_hassol:
249+
objval = model.objval
250+
x_sol = model.getValues()
251+
lambda_sol = model.getDuals()
252+
else:
253+
objval = +np.inf
254+
x_sol = [0.0] * n
255+
lambda_sol = [0.0] * m
256+
257+
num_iters = model.barrieriter
258+
solve_time_real = model.solvingtime
259+
260+
# Return results in dictionary format expected by invert()
261+
solution = {
262+
'status': nlp_status,
263+
'obj_val': objval,
264+
'x': np.array(x_sol),
265+
'lambda': np.array(lambda_sol),
266+
'num_iters': num_iters,
267+
'solve_time_real': solve_time_real
268+
}
269+
270+
return solution
110271

111272
def cite(self, data):
112273
"""Returns bibtex citation for the solver.

cvxpy/tests/nlp_tests/test_interfaces.py

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -248,8 +248,21 @@ def test_knitro_solver_stats(self):
248248
@pytest.mark.skipif('COPT' not in INSTALLED_SOLVERS, reason='COPT is not installed.')
249249
class TestCOPTInterface:
250250

251-
def test_copt_call(self):
251+
def test_copt_basic_solve(self):
252+
"""Test that COPT can solve a basic NLP problem."""
252253
x = cp.Variable()
253254
prob = cp.Problem(cp.Minimize((x - 2) ** 2), [x >= 1])
254-
with pytest.raises(NotImplementedError):
255-
prob.solve(solver=cp.COPT, nlp=True)
255+
prob.solve(solver=cp.COPT, nlp=True)
256+
assert prob.status == cp.OPTIMAL
257+
assert np.isclose(x.value, 2.0, atol=1e-5)
258+
259+
def test_copt_maxit(self):
260+
"""Test maximum iterations option."""
261+
x = cp.Variable(2)
262+
x.value = np.array([1.0, 1.0])
263+
prob = cp.Problem(cp.Minimize(x[0]**2 + x[1]**2), [x[0] + x[1] >= 1])
264+
265+
# Use a reasonable maxit value that allows convergence
266+
prob.solve(solver=cp.COPT, nlp=True, NLPIterLimit=100)
267+
assert prob.status == cp.OPTIMAL
268+
assert np.allclose(x.value, [0.5, 0.5], atol=1e-4)

cvxpy/tests/nlp_tests/test_nlp_solvers.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,8 @@
1414
not is_knitro_available(), reason='KNITRO is not installed or license not available.')),
1515
pytest.param('UNO', marks=pytest.mark.skipif(
1616
'UNO' not in INSTALLED_SOLVERS, reason='UNO is not installed.')),
17+
pytest.param('COPT', marks=pytest.mark.skipif(
18+
'COPT' not in INSTALLED_SOLVERS, reason='COPT is not installed.')),
1719
]
1820

1921

@@ -246,7 +248,7 @@ def test_circle_packing_formulation_two(self, solver):
246248
min_dist_sq = (radius[i] + radius[j]) ** 2
247249
residuals.append(dist_sq - min_dist_sq)
248250

249-
assert(np.all(np.array(residuals) <= 1e-8))
251+
assert(np.all(np.array(residuals) <= 1e-6))
250252

251253
# Ipopt finds these centers, but Knitro rotates them (but finds the same
252254
# objective value)

0 commit comments

Comments
 (0)