diffapqp 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
lao/utils.py ADDED
@@ -0,0 +1,459 @@
1
+ """
2
+ Helper functions
3
+ """
4
+
5
+ import cvxpy as cp
6
+ import numpy as np
7
+ import scipy.sparse as ssparse
8
+
9
+
10
+ def _to_dense_array(matrix):
11
+ return matrix.toarray() if ssparse.issparse(matrix) else np.asarray(matrix)
12
+
13
+
14
+ def _to_dense_vector(matrix):
15
+ return np.asarray(_to_dense_array(matrix)).reshape(-1)
16
+
17
+
18
+ def _matrix_column(matrix, col_idx):
19
+ if ssparse.issparse(matrix):
20
+ return matrix[:, [col_idx]]
21
+ return matrix[:, col_idx]
22
+
23
+
24
+ def _has_nonzero_entries(matrix):
25
+ if ssparse.issparse(matrix):
26
+ return bool(np.count_nonzero(matrix.data))
27
+ return bool(np.count_nonzero(np.asarray(matrix)))
28
+
29
+
30
+ def _expr_has_parameters(expr):
31
+ return len(expr.parameters()) > 0
32
+
33
+
34
+ def _iter_expression_tree(expr):
35
+ # Walk CVXPY expression nodes depth-first through their child args.
36
+ # including objective and constraints
37
+ yield expr
38
+ for child in getattr(expr, "args", []):
39
+ yield from _iter_expression_tree(child)
40
+
41
+
42
+ def validate_apqp_problem(
43
+ prob,
44
+ *,
45
+ canonicalize_solver="OSQP",
46
+ require_dpp=True,
47
+ require_continuous=True,
48
+ ):
49
+ """Validate that a CVXPY problem matches the APQP/APLP form expected by DiffAPQP.
50
+
51
+ Accepted form:
52
+
53
+ min 1/2 x^T P x + q(theta)^T x
54
+ s.t. A x = b(theta)
55
+ G x <= h(theta)
56
+
57
+ where parameters may appear affinely in the linear objective and constraint
58
+ offsets. Equivalent syntactic placements such as ``x + p <= c`` are
59
+ accepted if CVXPY canonicalization leaves the variable coefficient matrices
60
+ parameter-free. Parameterized P, A, or G are rejected.
61
+ """
62
+
63
+ # minimum test
64
+ if prob is None:
65
+ raise ValueError("A CVXPY problem instance is required for validation.")
66
+
67
+ if not isinstance(prob.objective, cp.Minimize):
68
+ raise ValueError("DiffAPQP only supports minimization problems.")
69
+
70
+ if not prob.is_dcp():
71
+ raise ValueError("DiffAPQP requires a DCP-compliant CVXPY problem.")
72
+
73
+ if not prob.is_qp():
74
+ raise ValueError("DiffAPQP only supports QP/LP problems in CVXPY's QP class.")
75
+
76
+ if prob.parameters() and require_dpp and not prob.is_dpp():
77
+ raise ValueError(
78
+ "DiffAPQP requires a DPP-compliant parametric problem. "
79
+ "Parameters must enter affinely in the supported APQP/APLP form."
80
+ )
81
+
82
+ # Parameters may not appear inside the quadratic matrix P(theta).
83
+ for node in _iter_expression_tree(prob.objective.expr):
84
+ if type(node).__name__ == "QuadForm" and _expr_has_parameters(node):
85
+ raise ValueError(
86
+ "DiffAPQP does not support parameterized quadratic matrices. "
87
+ "Parameters may appear only in the linear objective term and "
88
+ "constraint right-hand sides."
89
+ )
90
+
91
+ # Constraints must be affine in variables/parameters. We do not require the
92
+ # original CVXPY left-hand side to be parameter-free, because expressions
93
+ # like x + p <= c are equivalent to x <= c - p and still have constant
94
+ # variable coefficients after canonicalization.
95
+ for idx, constr in enumerate(prob.constraints):
96
+ if not constr.expr.is_affine():
97
+ raise ValueError(
98
+ f"Constraint {idx} is not affine. DiffAPQP supports only "
99
+ "affine equality and inequality constraints."
100
+ )
101
+
102
+ # Currently we only support continuous problems: all variables must be continuous.
103
+ if require_continuous:
104
+ has_discrete_vars = any(
105
+ bool(getattr(var, "boolean_idx", [])) or bool(getattr(var, "integer_idx", []))
106
+ for var in prob.variables()
107
+ )
108
+ if has_discrete_vars:
109
+ raise ValueError(
110
+ "DiffAPQP layers currently support only continuous problems. "
111
+ "Relax integer/boolean variables before using the differentiable layers."
112
+ )
113
+
114
+ param_qp_prog, _, zero_dim, int_dim, bool_dim = return_compiler(
115
+ prob, canonicalize_solver=canonicalize_solver
116
+ )
117
+
118
+ if _has_nonzero_entries(param_qp_prog.P[:, :-1]):
119
+ raise ValueError(
120
+ "DiffAPQP does not support parameterized quadratic matrices. "
121
+ "Parameters may appear only in the linear objective term and "
122
+ "constraint offsets."
123
+ )
124
+
125
+ no_cons = param_qp_prog.constr_size
126
+ if no_cons > 0 and _has_nonzero_entries(param_qp_prog.A[:-no_cons, :-1]):
127
+ raise ValueError(
128
+ "DiffAPQP does not support parameterized constraint matrices. "
129
+ "Parameters may shift constraint offsets, but A and G must remain constant."
130
+ )
131
+
132
+ if require_continuous:
133
+ if len(int_dim) > 0 or len(bool_dim) > 0:
134
+ raise ValueError(
135
+ "DiffAPQP layers currently support only continuous problems. "
136
+ "Relax integer/boolean variables before using the differentiable layers."
137
+ )
138
+
139
+
140
+ def is_apqp_problem(prob, **kwargs):
141
+ """Return True iff ``validate_apqp_problem`` succeeds."""
142
+ try:
143
+ validate_apqp_problem(prob, **kwargs)
144
+ except Exception:
145
+ return False
146
+ return True
147
+
148
+
149
+ def _flattened_size(shape):
150
+ """Return flattened size for scalar/vector/matrix CVXPY objects. This is also the number of elements"""
151
+ if len(shape) == 0:
152
+ return 1
153
+ return int(np.prod(shape))
154
+
155
+
156
+ def flatten_fortran_numpy_sample(value):
157
+ """Flatten a single CVXPY-shaped value using default CVXPY/Fortran order."""
158
+ arr = np.asarray(value)
159
+ if arr.ndim == 0:
160
+ # scalar
161
+ return np.array([arr.item()])
162
+ if arr.ndim == 1:
163
+ # vector
164
+ return arr
165
+ # matrix
166
+ return arr.reshape(-1, order="F")
167
+
168
+
169
+ def unflatten_fortran_torch_batch(x_flat, shape):
170
+ """Recover batched flat CVXPY data into its original shape."""
171
+ # x_flat.shape == (batch_size, flattened_size)
172
+ # shape is the original shape of the variable
173
+ if len(shape) == 0:
174
+ return x_flat[:, 0]
175
+ if len(shape) == 1:
176
+ return x_flat
177
+
178
+ view_shape = (x_flat.shape[0], *reversed(shape))
179
+ permute_dims = (0, *range(len(shape), 0, -1))
180
+ return x_flat.reshape(view_shape).permute(permute_dims)
181
+
182
+
183
+ def flatten_fortran_torch_batch(x, shape):
184
+ """Flatten batched original-shaped data using CVXPY/Fortran order."""
185
+ if len(shape) == 0:
186
+ return x.reshape(x.shape[0], 1)
187
+ if len(shape) == 1:
188
+ return x.reshape(x.shape[0], -1)
189
+
190
+ permute_dims = (0, *range(x.ndim - 1, 0, -1))
191
+ return x.permute(permute_dims).reshape(x.shape[0], -1)
192
+
193
+ def return_compiler(prob, canonicalize_solver="OSQP"):
194
+ """
195
+ return the compiler of the problem given by cvxpy
196
+ args:
197
+ - prob: the cvxpy problem
198
+ - canonicalize_solver: cvxpy solver name used for problem stuffing
199
+ default is open-source solver OSQP
200
+ return:
201
+ - param_qp_prog: the qp problem in standard form
202
+ - params_idx: {param_id: param_name}, link the id to the parameter name
203
+ - zero_dim: the number of equality constraint in the standard form
204
+ (always be the first rows in the matrix)
205
+ - int_vars_idx: the index of integer variables
206
+ - bool_vars_idx: the index of boolean variables
207
+ """
208
+
209
+ # canonicalize the problem
210
+ solver_name = canonicalize_solver.upper()
211
+ if not hasattr(cp, solver_name):
212
+ raise ValueError(f"Unknown CVXPY solver for canonicalization: {canonicalize_solver}")
213
+ solver = getattr(cp, solver_name)
214
+ solver_opts = {"use_quad_obj": True} if solver_name == "GUROBI" else {}
215
+ data, _, _ = prob.get_problem_data(
216
+ solver=solver, solver_opts=solver_opts)
217
+
218
+ assert prob.is_qp(), 'only support QP (and LP) for now'
219
+ assert data['dims'].exp == 0, 'does not support exponential cone'
220
+ assert len(data['dims'].psd) == 0, 'does not support positive semidefinite cone'
221
+ # assert len(data['dims'].soc) == 0, 'does not support second-order cone'
222
+
223
+ # parametric QP problem
224
+ param_qp_prog = data[cp.settings.PARAM_PROB]
225
+
226
+ # ! the order of parameter idx is changed internally in cvxpy so we link the parameter id to its name
227
+ params_idx = {p.id: p.name() for p in prob.parameters()}
228
+
229
+ return param_qp_prog, params_idx, data['dims'].zero, data['int_vars_idx'], data['bool_vars_idx']
230
+
231
+ def return_standard_form(prob, sparse=False, canonicalize_solver="OSQP"):
232
+ """
233
+ This function will identify all the
234
+ parameters (data) from the param_qp_prog and return the standard form.
235
+
236
+ The standard form we consider is:
237
+ min 1/2 x^T P x + (q + sum Q_i z_i)^T x + c + sum d_i^T z_i
238
+ s.t. A x = b + sum B_i z_i
239
+ G x <= h + sum H_i z_i
240
+ in which x is the decision variable, z_i is the i-th parameter.
241
+ args:
242
+ - prob: the cvxpy problem
243
+ - sparse: whether return sparse matrices
244
+ - canonicalize_solver: cvxpy solver name used for problem stuffing
245
+ """
246
+ if prob is not None:
247
+ validate_apqp_problem(prob, canonicalize_solver=canonicalize_solver)
248
+
249
+ # canonicalize the problem
250
+ param_qp_prog, param_id_to_name, zero_dim, int_dim, bool_dim = return_compiler(
251
+ prob, canonicalize_solver=canonicalize_solver
252
+ )
253
+
254
+ # Get the total number of constraints and variables
255
+ no_cons = param_qp_prog.constr_size
256
+ no_var = param_qp_prog.reduced_A.var_len
257
+
258
+ # Extract the constant quadratic cost matrix P without densifying the full
259
+ # parameter-to-P map, which has no_var**2 rows and can be enormous.
260
+ P = _to_dense_vector(_matrix_column(param_qp_prog.P, -1)).reshape(no_var, no_var)
261
+
262
+ # Extract the linear cost vector q
263
+ q_raw = _to_dense_array(param_qp_prog.q)
264
+ q = q_raw[:-1,-1] # q vector
265
+ Q_tilde = q_raw[:-1,:-1] # parameter matrices
266
+ c = float(q_raw[-1, -1]) # scalar objective offset
267
+ d_tilde = q_raw[-1, :-1] # parameter-to-offset vectors
268
+
269
+ # Extract and reshape the constraint matrix A_tilde
270
+ # This contains both equality (Ax=b) and inequality constraints (Gx<=h)
271
+ if no_cons > 0:
272
+ A_const_col = _to_dense_vector(_matrix_column(param_qp_prog.A, -1))
273
+ # A_tilde = A_raw[:int(no_cons * no_var), -1]
274
+ A_tilde = A_const_col[:-no_cons].reshape(no_var, no_cons).T
275
+ # b_tilde = A_raw[int(no_cons * no_var):, -1]
276
+ b_tilde = A_const_col[-no_cons:]
277
+ else:
278
+ # Keep explicit empty objects for unconstrained problems.
279
+ A_tilde = np.zeros((0, no_var))
280
+ b_tilde = np.zeros((0,))
281
+
282
+ # Split A_tilde into:
283
+ # A: equality constraints (first zero_dim rows)
284
+ # G: inequality constraints (remaining rows, with negative sign)
285
+ A = A_tilde[:zero_dim,:]
286
+ G = -A_tilde[zero_dim:,:]
287
+
288
+ # Split b_tilde into:
289
+ # b: equality constraints right-hand side (with negative sign)
290
+ # h: inequality constraints right-hand side
291
+ b = -b_tilde[:zero_dim]
292
+ h = b_tilde[zero_dim:]
293
+
294
+ # Extract the parameter coefficient matrix B_tilde
295
+ # B_tilde = A_raw[int(no_cons * no_var):, :-1]
296
+ if no_cons > 0:
297
+ B_tilde = _to_dense_array(param_qp_prog.A[-no_cons:, :-1])
298
+ else:
299
+ B_tilde = np.zeros((0, param_qp_prog.A.shape[1] - 1))
300
+
301
+ # Initialize dictionaries for parameter matrices
302
+ B = {} # For equality constraints (B_i)
303
+ H = {} # For inequality constraints (H_i)
304
+ Q = {} # For linear cost parameters (Q_i)
305
+ d = {} # For scalar objective-offset parameters (d_i)
306
+
307
+ # Get mappings for parameters
308
+ param_id_to_col = param_qp_prog.param_id_to_col # Maps parameter ID to starting column
309
+ param_id_to_size = param_qp_prog.param_id_to_size # Maps parameter ID to its size
310
+ var_name_to_size = {
311
+ v.name(): _flattened_size(v.shape) for v in param_qp_prog.variables
312
+ }
313
+
314
+ # For each parameter:
315
+ for key, start_idx in param_id_to_col.items():
316
+ if key == -1: # Skip if key is -1 (special value)
317
+ continue
318
+
319
+ size = param_id_to_size[key] # Get parameter size
320
+ name = param_id_to_name[key] # Get parameter name
321
+
322
+ # Extract parameter matrices:
323
+ # B[name]: how parameter affects equality constraints
324
+ # H[name]: how parameter affects inequality constraints
325
+ B[name] = -B_tilde[:zero_dim, start_idx:start_idx+size]
326
+ H[name] = B_tilde[zero_dim:, start_idx:start_idx+size]
327
+ Q[name] = Q_tilde[:, start_idx:start_idx+size]
328
+ d[name] = d_tilde[start_idx:start_idx+size]
329
+
330
+ if sparse:
331
+ P = ssparse.csc_matrix(P)
332
+ A = ssparse.csc_matrix(A)
333
+ G = ssparse.csc_matrix(G)
334
+ for key in B.keys():
335
+ B[key] = ssparse.csc_matrix(B[key])
336
+ for key in H.keys():
337
+ H[key] = ssparse.csc_matrix(H[key])
338
+
339
+ return P, q, Q, c, d, A, G, b, h, B, H, var_name_to_size, int_dim, bool_dim # Q, d, B, H are dictionaries
340
+
341
+ def return_standard_form_in_cvxpy(prob, sparse=False, canonicalize_solver="OSQP"):
342
+ """
343
+ Return the standard form of the problem formulated as cvxpy problem
344
+ args:
345
+ - prob: the cvxpy problem, the parameters must be assigned a name
346
+ - sparse: whether to return the sparse matrix, default is False
347
+ - canonicalize_solver: cvxpy solver name used for problem stuffing
348
+ return:
349
+ - prob: the cvxpy problem in standard form
350
+ - prob_data: the data of the problem, including P, q, c, A, G, b, h, Q, d, B, H
351
+ Standard form:
352
+ min 1/2 x^T P x + q^T x + c
353
+ s.t. A x = b + sum B_i z_i
354
+ G x <= h + sum H_i z_i
355
+ in which x is the decision variable, z_i is the i-th parameter
356
+ NOTE: the formulation accepts multiple parameters for both inequality and equality constraints
357
+ NOTE: you still need to assign the parameters by param.value = your_value to the problem after this conversion
358
+ NOTE: Only support continuous variables, So for discrete QP, the discrete variables must be relaxed first.
359
+ """
360
+
361
+ P, q, Q, c, d, A, G, b, h, B, H, var_name_to_size, int_dim, bool_dim = return_standard_form(
362
+ prob, sparse=sparse, canonicalize_solver=canonicalize_solver
363
+ )
364
+
365
+ x = cp.Variable(P.shape[1])
366
+ # Build parameters from the union of objective/constraint parameter sets.
367
+ # Some parameters may appear only in Q (objective), or only in B/H (constraints).
368
+ param_keys = list(dict.fromkeys(list(Q.keys()) + list(d.keys()) + list(B.keys()) + list(H.keys())))
369
+ parameters = {}
370
+ for key in param_keys:
371
+ if key in Q:
372
+ size = Q[key].shape[1]
373
+ elif key in d:
374
+ size = d[key].shape[0]
375
+ elif key in B:
376
+ size = B[key].shape[1]
377
+ else:
378
+ size = H[key].shape[1]
379
+ parameters[key] = cp.Parameter(size, name=key)
380
+
381
+ # formulate the cvxpy problem
382
+ constraints = []
383
+
384
+ q_ = cp.Constant(np.zeros(P.shape[0]))
385
+ c_ = cp.Constant(c)
386
+ b_ = cp.Constant(np.zeros(A.shape[0])) if A.shape[0] > 0 else None
387
+ h_ = cp.Constant(np.zeros(G.shape[0])) if G.shape[0] > 0 else None
388
+
389
+ for key in parameters.keys():
390
+ if key in B and b_ is not None:
391
+ b_ += B[key] @ parameters[key]
392
+ if key in H and h_ is not None:
393
+ h_ += H[key] @ parameters[key]
394
+ if key in Q:
395
+ q_ += Q[key] @ parameters[key]
396
+ if key in d:
397
+ c_ += d[key] @ parameters[key]
398
+
399
+ if A.shape[0] > 0:
400
+ constraints.append(A @ x == b_ + b)
401
+ if G.shape[0] > 0:
402
+ constraints.append(G @ x <= h_ + h)
403
+
404
+ objective = cp.Minimize(0.5 * cp.quad_form(x, cp.psd_wrap(P)) + (q_ + q) @ x + c_)
405
+ prob = cp.Problem(objective, constraints)
406
+
407
+ return prob, var_name_to_size, {"P": P, "Q": Q, "q": q, "c": c, "d": d, "A": A, "G": G, "b": b, "h": h, "B": B, "H": H}
408
+
409
+ def get_default_solver_args(solver, eps = None):
410
+
411
+ if solver.upper() == "OSQP":
412
+ # see https://osqp.org/docs/interfaces/solver_settings.html
413
+ solver_args = {
414
+ "eps_abs": 1e-5 if eps is None else eps, # in cvxpy, the default is 1e-5
415
+ "eps_rel": 1e-5 if eps is None else eps, # in cvxpy, the default is 1e-5
416
+ "max_iter": 100000,
417
+ }
418
+ elif solver.upper() == "GUROBI":
419
+ solver_args = {
420
+ "Threads": 1, # this will limit the number of threads to 1, each problem is solved in a single cpu core
421
+ "OptimalityTol": 1e-8 if eps is None else eps, # set as default # ! for simplex method
422
+ "FeasibilityTol": 1e-8 if eps is None else eps, # set as default
423
+ "BarConvTol": 1e-8 if eps is None else eps, # ! this is for the barrier method
424
+ }
425
+ elif solver.upper() == "SCS":
426
+ solver_args = {
427
+ "eps_abs": 1e-6 if eps is None else eps, # default 1e-4
428
+ "eps_rel": 1e-6 if eps is None else eps, # default 1e-4
429
+ "max_iters": 10000,
430
+ }
431
+ elif solver.upper() == "ECOS":
432
+ solver_args = {
433
+ "abstol": 1e-8 if eps is None else eps,
434
+ "reltol": 1e-8 if eps is None else eps,
435
+ "max_iters": 200,
436
+ }
437
+ elif solver.upper() == "CLARABEL":
438
+ solver_args = {
439
+ "tol_gap_abs": 1e-8 if eps is None else eps,
440
+ "tol_gap_rel": 1e-8 if eps is None else eps,
441
+ "max_iter": 1000
442
+ }
443
+ elif solver.upper() == "QPALM":
444
+ solver_args = {
445
+ "eps_abs": 1e-6 if eps is None else eps,
446
+ "eps_rel": 1e-6 if eps is None else eps,
447
+ "max_iter": 10000,
448
+ }
449
+ elif solver.upper() == "PROXQP":
450
+ solver_args = {
451
+ "eps_abs": 1e-6 if eps is None else eps,
452
+ "eps_rel": 1e-6 if eps is None else eps,
453
+ "max_iter": 10000,
454
+ "backend": "sparse" # solve the memory issue
455
+ }
456
+ else:
457
+ solver_args = {}
458
+
459
+ return solver_args