elastiqp 0.2.0__cp313-cp313-win_amd64.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.
elastiqp/__init__.py ADDED
@@ -0,0 +1,124 @@
1
+ """ElastiQP: an always-feasible QP solver for constrained robot control"""
2
+
3
+
4
+ def _import_core(): # compiled nanobind core
5
+ import os
6
+
7
+ # Let ELASTIQP_BUILD_DIR take priority for ctest
8
+ env_dir = os.environ.get("ELASTIQP_BUILD_DIR")
9
+ if env_dir:
10
+ import importlib.machinery
11
+ import importlib.util
12
+ import sys
13
+
14
+ for suffix in importlib.machinery.EXTENSION_SUFFIXES:
15
+ path = os.path.join(env_dir, "_core" + suffix)
16
+ if not os.path.exists(path):
17
+ continue
18
+ spec = importlib.util.spec_from_file_location("elastiqp._core", path)
19
+ core = importlib.util.module_from_spec(spec)
20
+ sys.modules["elastiqp._core"] = core
21
+ spec.loader.exec_module(core)
22
+ return core
23
+ try:
24
+ from elastiqp import _core
25
+
26
+ return _core
27
+ except ImportError:
28
+ pass
29
+ # Un-configured source checkout: the extension defaults to <repo>/build.
30
+ from pathlib import Path
31
+
32
+ build = str(Path(__file__).resolve().parents[2] / "build")
33
+ if os.path.isdir(build) and build not in __path__:
34
+ __path__.append(build)
35
+ try:
36
+ from elastiqp import _core
37
+ except ImportError as e:
38
+ raise ImportError(
39
+ "elastiqp._core extension not found. Install the package "
40
+ "(pip install .) or build from source (cmake -B build . && "
41
+ "cmake --build build), optionally pointing ELASTIQP_BUILD_DIR "
42
+ "at the build directory."
43
+ ) from e
44
+ return _core
45
+
46
+
47
+ import sys as _sys
48
+
49
+ _core = _import_core()
50
+
51
+ Solution = _core.Solution
52
+ Status = _core.Status
53
+ solve = _core.solve
54
+
55
+ # One submodule per backend, mirroring the C++ namespaces: each has its own
56
+ # Settings and Solver (das.RowState too). Registered in sys.modules so that
57
+ # `import elastiqp.das` works as well as `elastiqp.das`.
58
+ das = _core.das
59
+ pdal = _core.pdal
60
+ ipm = _core.ipm
61
+ for _name, _mod in (("das", das), ("pdal", pdal), ("ipm", ipm)):
62
+ _sys.modules[f"elastiqp.{_name}"] = _mod
63
+ RowState = das.RowState
64
+
65
+ METHODS = ("das", "pdal", "ipm")
66
+ _BACKENDS = {"das": das, "pdal": pdal, "ipm": ipm}
67
+
68
+
69
+ def _lookup(table, method):
70
+ try:
71
+ return table[method]
72
+ except KeyError:
73
+ raise ValueError(f"method must be one of {METHODS}, got {method!r}") from None
74
+
75
+
76
+ def Solver(method="das"):
77
+ """Factory: a solver object for the given backend.
78
+
79
+ method is "das" (dual active set, the default), "pdal" (primal-dual
80
+ augmented Lagrangian) or "ipm" (interior point); the returned object is
81
+ an elastiqp.das.Solver / pdal.Solver / ipm.Solver. All three share
82
+ setup(), update(), set_*(), solve(), relax() (the differentiation point)
83
+ and the Solution type.
84
+ """
85
+ return _lookup(_BACKENDS, method).Solver()
86
+
87
+
88
+ # Per-backend outer iteration budget (the jax/torch wrappers' max_iter=None),
89
+ # read from each backend's Settings so it cannot drift from C++.
90
+ _DEFAULT_MAX_ITER = {
91
+ "das": das.Settings().max_iter,
92
+ "pdal": pdal.Settings().max_outer_iter,
93
+ "ipm": ipm.Settings().max_iter,
94
+ }
95
+
96
+
97
+ def Settings(method="das"):
98
+ """Factory: a default settings object for the given backend
99
+ (elastiqp.das.Settings / pdal.Settings / ipm.Settings); assign to
100
+ solver.settings."""
101
+ return _lookup(_BACKENDS, method).Settings()
102
+
103
+
104
+ __all__ = [
105
+ "METHODS",
106
+ "das",
107
+ "pdal",
108
+ "ipm",
109
+ "RowState",
110
+ "Settings",
111
+ "Solution",
112
+ "Solver",
113
+ "Status",
114
+ "solve",
115
+ ]
116
+
117
+ try:
118
+ from importlib.metadata import version as _version
119
+
120
+ __version__ = _version("elastiqp")
121
+ except Exception: # not installed (source checkout)
122
+ __version__ = "0.0.0.dev0"
123
+
124
+ del _core, _import_core, _name, _mod
Binary file
Binary file
elastiqp/jax.py ADDED
@@ -0,0 +1,501 @@
1
+ """ElastiQP JAX FFI wrapper
2
+
3
+ Supported:
4
+ - Different methods (das, pdal, ipm)
5
+ - jit, vmap, scan
6
+ - grad
7
+
8
+ Notes:
9
+ - vmap is sequential mode
10
+ - Requires float64
11
+ - Warm-starting is not differentiable
12
+ - Warm-starting requires explicit passing of the previous result
13
+ - Recommended default kappa for differentiability: 1e-3
14
+ """
15
+
16
+ import ctypes
17
+ from functools import partial
18
+ from pathlib import Path
19
+ from typing import NamedTuple
20
+
21
+ try:
22
+ import jax
23
+ except ImportError as e:
24
+ import sys
25
+
26
+ if sys.version_info < (3, 11):
27
+ raise ImportError(
28
+ "elastiqp.jax requires Python >= 3.11 (for jax>=0.9.1)"
29
+ ) from e
30
+ raise ImportError("elastiqp.jax requires jax to be installed") from e
31
+
32
+ from jax import Array
33
+ import jax.numpy as jnp
34
+ import numpy as np
35
+
36
+ from elastiqp import _DEFAULT_MAX_ITER, _core
37
+
38
+ __all__ = ["solve", "Result", "METHODS"]
39
+
40
+ METHODS = ("das", "pdal", "ipm")
41
+ _METHOD_ID = {"das": 0, "pdal": 1, "ipm": 2}
42
+
43
+
44
+ def _find_library():
45
+ # The FFI library is built and installed next to the compiled core, so
46
+ # look wherever elastiqp._core was found (installed wheel, editable
47
+ # install, ELASTIQP_BUILD_DIR, or <repo>/build).
48
+ directory = Path(_core.__file__).resolve().parent
49
+ for name in (
50
+ "libelastiqp_jax_ffi.so",
51
+ "elastiqp_jax_ffi.so",
52
+ "elastiqp_jax_ffi.dll",
53
+ "libelastiqp_jax_ffi.dylib",
54
+ ):
55
+ path = directory / name
56
+ if path.exists():
57
+ return path
58
+ raise ImportError(
59
+ f"elastiqp_jax_ffi shared library not found in {directory} (next to "
60
+ "elastiqp._core). Reinstall the package, or for source checkouts "
61
+ "build it first (cmake --build build)."
62
+ )
63
+
64
+
65
+ _lib = ctypes.cdll.LoadLibrary(str(_find_library()))
66
+ jax.ffi.register_ffi_target(
67
+ "elastiqp_solve", jax.ffi.pycapsule(_lib.ElastiqpSolve), platform="cpu"
68
+ )
69
+ jax.ffi.register_ffi_target(
70
+ "elastiqp_solve_warm",
71
+ jax.ffi.pycapsule(_lib.ElastiqpSolveWarm),
72
+ platform="cpu",
73
+ )
74
+
75
+
76
+ class Result(NamedTuple):
77
+ x: jax.Array
78
+ t: jax.Array
79
+ y: jax.Array
80
+ z_t: jax.Array
81
+ z: jax.Array
82
+ converged: jax.Array
83
+ iters: jax.Array
84
+
85
+
86
+ def _ffi_solve(
87
+ Q,
88
+ q,
89
+ A,
90
+ b,
91
+ G,
92
+ h,
93
+ penalty,
94
+ eps_abs,
95
+ max_iter,
96
+ ruiz,
97
+ target_kappa,
98
+ method,
99
+ ):
100
+ n = Q.shape[-1]
101
+ m = b.shape[-1]
102
+ p = h.shape[-1]
103
+ batch = Q.shape[:-2]
104
+ vec = lambda d: jax.ShapeDtypeStruct(batch + (d,), jnp.float64)
105
+ # (x, t, y, z_t, z) tight solution, then the kappa-relaxed central
106
+ # point (identical to the tight block when target_kappa <= 0), then
107
+ # info = [converged, iters, relax_converged].
108
+ out_types = [
109
+ vec(n),
110
+ vec(p),
111
+ vec(m),
112
+ vec(p),
113
+ vec(p),
114
+ vec(n),
115
+ vec(p),
116
+ vec(m),
117
+ vec(p),
118
+ vec(p),
119
+ vec(3),
120
+ ]
121
+ call = jax.ffi.ffi_call("elastiqp_solve", out_types, vmap_method="sequential")
122
+ return call(
123
+ Q,
124
+ q,
125
+ A,
126
+ b,
127
+ G,
128
+ h,
129
+ penalty,
130
+ eps_abs=np.float64(eps_abs),
131
+ max_iter=np.int64(max_iter),
132
+ ruiz=np.int64(bool(ruiz)),
133
+ target_kappa=np.float64(target_kappa),
134
+ method=np.int64(_METHOD_ID[method]),
135
+ )
136
+
137
+
138
+ def _ffi_solve_warm(
139
+ Q,
140
+ q,
141
+ A,
142
+ b,
143
+ G,
144
+ h,
145
+ penalty,
146
+ x0,
147
+ y0,
148
+ z0,
149
+ eps_abs,
150
+ max_iter,
151
+ ruiz,
152
+ method,
153
+ ):
154
+ n = Q.shape[-1]
155
+ m = b.shape[-1]
156
+ p = h.shape[-1]
157
+ batch = Q.shape[:-2]
158
+ vec = lambda d: jax.ShapeDtypeStruct(batch + (d,), jnp.float64)
159
+ # (x, t, y, z_t, z), then info = [converged, iters].
160
+ out_types = [vec(n), vec(p), vec(m), vec(p), vec(p), vec(2)]
161
+ call = jax.ffi.ffi_call("elastiqp_solve_warm", out_types, vmap_method="sequential")
162
+ return call(
163
+ Q,
164
+ q,
165
+ A,
166
+ b,
167
+ G,
168
+ h,
169
+ penalty,
170
+ x0,
171
+ y0,
172
+ z0,
173
+ eps_abs=np.float64(eps_abs),
174
+ max_iter=np.int64(max_iter),
175
+ ruiz=np.int64(bool(ruiz)),
176
+ method=np.int64(_METHOD_ID[method]),
177
+ )
178
+
179
+
180
+ @partial(jax.custom_vjp, nondiff_argnums=(10, 11, 12, 13))
181
+ def _solve_warm(
182
+ Q,
183
+ q,
184
+ A,
185
+ b,
186
+ G,
187
+ h,
188
+ penalty,
189
+ x0,
190
+ y0,
191
+ z0,
192
+ eps_abs,
193
+ max_iter,
194
+ ruiz,
195
+ method,
196
+ ):
197
+ return _ffi_solve_warm(
198
+ Q,
199
+ q,
200
+ A,
201
+ b,
202
+ G,
203
+ h,
204
+ penalty,
205
+ x0,
206
+ y0,
207
+ z0,
208
+ eps_abs,
209
+ max_iter,
210
+ ruiz,
211
+ method,
212
+ )
213
+
214
+
215
+ def _solve_warm_fwd(*args):
216
+ raise TypeError("elastiqp.jax.solve is not differentiable with warm_start")
217
+
218
+
219
+ def _solve_warm_bwd(*args):
220
+ raise TypeError("elastiqp.jax.solve is not differentiable with warm_start")
221
+
222
+
223
+ _solve_warm.defvjp(_solve_warm_fwd, _solve_warm_bwd)
224
+
225
+
226
+ def _outer(a, b):
227
+ return a[..., :, None] * b[..., None, :]
228
+
229
+
230
+ # Note: this funciton is a port of equivalent logic in qpax
231
+ # See also: kkt_vjp.hpp (c++ version) and _vjp_torch in torch.py
232
+ def _kkt_bwd(res, ct):
233
+ """Implicit differentiation of the elastic KKT conditions."""
234
+ Q, A, G, h, x, t, y, z_t, z = res
235
+ xb, tb, yb, z_tb, zb, _ = ct
236
+ n = Q.shape[-1]
237
+ m = A.shape[-2]
238
+
239
+ Qs = 0.5 * (Q + jnp.swapaxes(Q, -1, -2))
240
+ Gt = jnp.swapaxes(G, -1, -2)
241
+ At = jnp.swapaxes(A, -1, -2)
242
+ mv = lambda M, v: jnp.einsum("...ij,...j->...i", M, v)
243
+
244
+ D = mv(G, x) - t - h
245
+ E = D - z * t / z_t
246
+
247
+ # Eliminating the t, z_t, z rows reduces the adjoint system to (x, y).
248
+ # Hard rows (penalty = inf: t = 0, z_t = inf) have no t block.
249
+ rt = jnp.where(jnp.isfinite(z_t), -z_t * z_tb + t * tb, 0.0)
250
+ rh = z * zb + z * rt / z_t
251
+ rhs_x = xb - mv(Gt, rh / E)
252
+
253
+ H = Qs + jnp.einsum("...ji,...j,...jk->...ik", G, -z / E, G)
254
+ KKT = jnp.concatenate(
255
+ [
256
+ jnp.concatenate([H, At], axis=-1),
257
+ jnp.concatenate([A, jnp.zeros(A.shape[:-2] + (m, m), Q.dtype)], axis=-1),
258
+ ],
259
+ axis=-2,
260
+ )
261
+ vxy = jnp.linalg.solve(KKT, jnp.concatenate([rhs_x, yb], axis=-1))
262
+ vx = vxy[..., :n]
263
+ vy = vxy[..., n:]
264
+
265
+ vh = (rh - z * mv(G, vx)) / E
266
+ vpen = (rt + t * vh) / z_t
267
+
268
+ qb = -vx
269
+ penalty_b = -vpen
270
+ bb = vy
271
+ hb = vh
272
+ Qb = -0.5 * (_outer(vx, x) + _outer(x, vx))
273
+ Ab = -(_outer(y, vx) + _outer(vy, x))
274
+ Gb = -(_outer(z, vx) + _outer(vh, x))
275
+ return Qb, qb, Ab, bb, Gb, hb, penalty_b
276
+
277
+
278
+ @partial(jax.custom_vjp, nondiff_argnums=(7, 8, 9, 10, 11))
279
+ def _solve(
280
+ Q,
281
+ q,
282
+ A,
283
+ b,
284
+ G,
285
+ h,
286
+ penalty,
287
+ eps_abs,
288
+ max_iter,
289
+ ruiz,
290
+ target_kappa,
291
+ method,
292
+ ):
293
+ # Tight solution when not differentiating
294
+ out = _ffi_solve(
295
+ Q,
296
+ q,
297
+ A,
298
+ b,
299
+ G,
300
+ h,
301
+ penalty,
302
+ eps_abs,
303
+ max_iter,
304
+ ruiz,
305
+ 0.0,
306
+ method,
307
+ )
308
+ return tuple(out[:5]) + (out[10],) # solution + info
309
+
310
+
311
+ def _solve_fwd(
312
+ Q,
313
+ q,
314
+ A,
315
+ b,
316
+ G,
317
+ h,
318
+ penalty,
319
+ eps_abs,
320
+ max_iter,
321
+ ruiz,
322
+ target_kappa,
323
+ method,
324
+ ):
325
+ if not target_kappa > 0:
326
+ raise TypeError(
327
+ "elastiqp.jax.solve is not differentiable with target_kappa=0: "
328
+ "Set target_kappa > 0 (e.g. 1e-3) for smoothed gradients"
329
+ )
330
+ out = _ffi_solve(
331
+ Q,
332
+ q,
333
+ A,
334
+ b,
335
+ G,
336
+ h,
337
+ penalty,
338
+ eps_abs,
339
+ max_iter,
340
+ ruiz,
341
+ target_kappa,
342
+ method,
343
+ )
344
+ x, t, y, z_t, z, xr, tr, yr, z_t_r, z_r, info = out
345
+ return (x, t, y, z_t, z, info), (Q, A, G, h, xr, tr, yr, z_t_r, z_r)
346
+
347
+
348
+ def _solve_bwd(eps_abs, max_iter, ruiz, target_kappa, method, res, ct):
349
+ return _kkt_bwd(res, ct)
350
+
351
+
352
+ _solve.defvjp(_solve_fwd, _solve_bwd)
353
+
354
+
355
+ def solve(
356
+ Q: Array,
357
+ q: Array,
358
+ G: Array,
359
+ h: Array,
360
+ penalty: Array | float,
361
+ *,
362
+ A: Array | None = None,
363
+ b: Array | None = None,
364
+ method: str = "das",
365
+ eps_abs: float = 1e-6,
366
+ max_iter: int | None = None,
367
+ ruiz: bool = True,
368
+ target_kappa: float = 1e-3,
369
+ warm_start: tuple[Array, Array, Array] | Result | None = None,
370
+ ) -> Result:
371
+ """Solve the elastic QP
372
+
373
+ min 0.5 x'Qx + q'x + penalty' t
374
+ s.t. A x == b, G x - t <= h, t >= 0
375
+
376
+ Args:
377
+ Q (Array): Quadratic cost matrix, shape (n, n)
378
+ q (Array): Quadratic cost vector, shape (n,)
379
+ G (Array): Linear inequality constraint matrix, shape (p, n)
380
+ h (Array): Linear inequality constraint vector, shape (p,)
381
+ penalty (Array | float): L1 penalty, shape (p,) if vector
382
+ A (Array, optional): Linear equality constraint matrix, shape (m, n).
383
+ Defaults to None.
384
+ b (Array, optional): Linear equality constraint vector, shape (m,).
385
+ Defaults to None.
386
+ method (str, optional): Backend (das/pdal/ipm). Defaults to "das".
387
+ eps_abs (float, optional): Absolute KKT tolerance (same meaning for
388
+ every backend). Defaults to 1e-6.
389
+ max_iter (int, optional): Outer iteration budget; what it counts
390
+ depends on the backend (das: active-set iterations, pdal: BCL
391
+ rounds, ipm: interior-point iterations). Defaults to None (the
392
+ backend's Settings default).
393
+ ruiz (bool, optional): Whether to use ruiz equilibration.
394
+ Defaults to True.
395
+ target_kappa (float, optional): Kappa-relaxation parameter for smooth
396
+ derivatives. Defaults to 1e-3.
397
+ warm_start (tuple | Result, optional): Explicit warm start, either a
398
+ previous Result or a (x, y, z) tuple, (das and pdal only).
399
+ Defaults to None.
400
+
401
+ Returns:
402
+ Result: Solution to the elastic QP
403
+ """
404
+ if method not in METHODS:
405
+ raise ValueError(f"method must be one of {METHODS}, got {method!r}")
406
+ if not jax.config.jax_enable_x64:
407
+ raise RuntimeError(
408
+ "elastiqp.jax.solve requires double precision. Enable it via "
409
+ "jax.config.update('jax_enable_x64', True) "
410
+ "or set JAX_ENABLE_X64=1 in your environment."
411
+ )
412
+ if max_iter is None:
413
+ max_iter = _DEFAULT_MAX_ITER[method]
414
+ Q = jnp.asarray(Q)
415
+ q = jnp.asarray(q)
416
+ G = jnp.asarray(G)
417
+ h = jnp.asarray(h)
418
+ if Q.ndim != 2 or q.ndim != 1:
419
+ raise ValueError("solve takes unbatched problems; use jax.vmap for batching")
420
+ n = Q.shape[-1]
421
+ if (A is None) != (b is None):
422
+ raise ValueError("A and b must be provided together")
423
+ if A is None:
424
+ A = jnp.zeros(Q.shape[:-2] + (0, n), dtype=jnp.float64)
425
+ b = jnp.zeros(Q.shape[:-2] + (0,), dtype=jnp.float64)
426
+ else:
427
+ A = jnp.asarray(A)
428
+ b = jnp.asarray(b)
429
+ # penalty may be a scalar or a per-constraint vector of length p
430
+ penalty = jnp.broadcast_to(jnp.asarray(penalty, dtype=jnp.float64), h.shape)
431
+
432
+ if warm_start is not None:
433
+ if method == "ipm":
434
+ raise ValueError(
435
+ "warm_start is not supported with method='ipm'; use 'das' or 'pdal'"
436
+ )
437
+ if isinstance(warm_start, Result):
438
+ x0, y0, z0 = warm_start.x, warm_start.y, warm_start.z
439
+ else:
440
+ x0, y0, z0 = warm_start
441
+ x0 = jnp.asarray(x0, dtype=jnp.float64)
442
+ y0 = jnp.asarray(y0, dtype=jnp.float64)
443
+ z0 = jnp.asarray(z0, dtype=jnp.float64)
444
+ if x0.shape != q.shape or y0.shape != b.shape or z0.shape != h.shape:
445
+ raise ValueError(
446
+ "warm_start (x, y, z) must have shapes "
447
+ f"{q.shape}, {b.shape}, {h.shape}; got "
448
+ f"{x0.shape}, {y0.shape}, {z0.shape}"
449
+ )
450
+ x, t, y, z_t, z, info = _solve_warm(
451
+ Q,
452
+ q,
453
+ A,
454
+ b,
455
+ G,
456
+ h,
457
+ penalty,
458
+ x0,
459
+ y0,
460
+ z0,
461
+ float(eps_abs),
462
+ int(max_iter),
463
+ bool(ruiz),
464
+ method,
465
+ )
466
+ return Result(
467
+ x=x,
468
+ t=t,
469
+ y=y,
470
+ z_t=z_t,
471
+ z=z,
472
+ converged=info[..., 0].astype(jnp.int32),
473
+ iters=info[..., 1].astype(jnp.int32),
474
+ )
475
+
476
+ x, t, y, z_t, z, info = _solve(
477
+ Q,
478
+ q,
479
+ A,
480
+ b,
481
+ G,
482
+ h,
483
+ penalty,
484
+ float(eps_abs),
485
+ int(max_iter),
486
+ bool(ruiz),
487
+ float(target_kappa),
488
+ method,
489
+ )
490
+ # Recall: info = [converged, iters, relax_converged]. The converged
491
+ # flag here considers both forward and backward (relax)
492
+ converged = jnp.minimum(info[..., 0], info[..., 2])
493
+ return Result(
494
+ x=x,
495
+ t=t,
496
+ y=y,
497
+ z_t=z_t,
498
+ z=z,
499
+ converged=converged.astype(jnp.int32),
500
+ iters=info[..., 1].astype(jnp.int32),
501
+ )