openscvx 2.dev6__py3-none-any.whl → 2.dev7__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.
- openscvx/__init__.py +16 -1
- openscvx/_version.py +2 -2
- openscvx/loader.py +2 -2
- openscvx/lowered/cvxpy_constraints.py +4 -1
- openscvx/problem.py +19 -13
- openscvx/solvers/__init__.py +40 -23
- openscvx/solvers/base.py +117 -36
- openscvx/solvers/cvxpy_ptr_solver.py +908 -0
- openscvx/solvers/ptr_solver.py +94 -843
- openscvx/solvers/qpax_ptr_solver.py +776 -0
- openscvx/symbolic/lower.py +6 -7
- openscvx/utils/printing.py +9 -1
- {openscvx-2.dev6.dist-info → openscvx-2.dev7.dist-info}/METADATA +3 -1
- {openscvx-2.dev6.dist-info → openscvx-2.dev7.dist-info}/RECORD +18 -16
- {openscvx-2.dev6.dist-info → openscvx-2.dev7.dist-info}/WHEEL +0 -0
- {openscvx-2.dev6.dist-info → openscvx-2.dev7.dist-info}/entry_points.txt +0 -0
- {openscvx-2.dev6.dist-info → openscvx-2.dev7.dist-info}/licenses/LICENSE +0 -0
- {openscvx-2.dev6.dist-info → openscvx-2.dev7.dist-info}/top_level.txt +0 -0
|
@@ -0,0 +1,776 @@
|
|
|
1
|
+
"""JAX-native QP backend for the PTR convex subproblem.
|
|
2
|
+
|
|
3
|
+
Assembles each SCP subproblem as a flat ``(Q, q, A, b, G, h)`` quadratic
|
|
4
|
+
program and dispatches it to ``qpax.solve_qp``. This backend is the wedge
|
|
5
|
+
toward an end-to-end JAX-differentiable SCP loop: ``qpax.solve_qp_primal``
|
|
6
|
+
exposes a ``jax.custom_vjp`` rule that lets gradients flow through the QP via
|
|
7
|
+
the implicit function theorem on the relaxed KKT system. The surrounding
|
|
8
|
+
pipeline (discretizer, algorithm, parameter sync) still breaks out of JIT
|
|
9
|
+
today; making it ``jit``-friendly is future work that turns this backend
|
|
10
|
+
from "another solver" into "differentiable SCvx", and would also enable
|
|
11
|
+
``jax.vmap`` batching across scenarios.
|
|
12
|
+
|
|
13
|
+
Scope
|
|
14
|
+
-----
|
|
15
|
+
* No user ``.convex()`` constraints (would need SOCP).
|
|
16
|
+
* No cross-node or impulsive controls. Each raises
|
|
17
|
+
:class:`NotImplementedError` at :meth:`initialize` time and points the user
|
|
18
|
+
at :class:`openscvx.solvers.cvxpy_ptr_solver.CVXPyPTRSolver` as the
|
|
19
|
+
alternative. Either may gain support here in the future; both add
|
|
20
|
+
non-trivial QP structure (block-coupled equality rows for impulsive,
|
|
21
|
+
full-trajectory gradient stacking for cross-node).
|
|
22
|
+
* CTCS constraints **are** supported — their LICQ-style absolute-value
|
|
23
|
+
inequalities reduce to two affine rows per node, which is plain QP form.
|
|
24
|
+
The library also auto-adds ``CTCS(time ≤ time.max)`` /
|
|
25
|
+
``CTCS(time.min ≤ time)`` to every problem, so this support is what
|
|
26
|
+
makes QPAX usable at all.
|
|
27
|
+
* No warm-start. ``qpax.solve_qp`` initializes its own primal-dual state on
|
|
28
|
+
every call and exposes no init hook; only ``qpax.relax_qp`` accepts a
|
|
29
|
+
warm-start tuple. SCvx warm-starting is a known performance gap vs
|
|
30
|
+
CVXPy+QOCO; could be threaded through here once upstream qpax exposes
|
|
31
|
+
the seam.
|
|
32
|
+
* Dense ``Q``, ``A``, ``G``. Trust-region terms are diagonal-dominant per
|
|
33
|
+
node and slack terms are sparse, so a sparse assembly may help in the
|
|
34
|
+
future; at ``N`` ≤ 100 the dense path is simpler and fast enough.
|
|
35
|
+
|
|
36
|
+
L1 / positive-part reformulation
|
|
37
|
+
--------------------------------
|
|
38
|
+
The PTR cost contains ``|nu|`` (virtual-control L1) and ``pos(nu_vb)``
|
|
39
|
+
(positive-part virtual buffer). Each ``|νᵢ|`` is replaced by a slack
|
|
40
|
+
``sᵢ`` plus ``±νᵢ - sᵢ ≤ 0`` (the implied ``sᵢ ≥ 0`` follows from
|
|
41
|
+
both inequalities); each ``pos(ν_vbᵢ)`` becomes ``sᵢ ≥ νᵢ`` *and*
|
|
42
|
+
``sᵢ ≥ 0`` (the latter is explicit because pos has no symmetric pair).
|
|
43
|
+
"""
|
|
44
|
+
|
|
45
|
+
from dataclasses import dataclass, field
|
|
46
|
+
from typing import TYPE_CHECKING, List, Optional, Tuple, Union
|
|
47
|
+
|
|
48
|
+
import numpy as np
|
|
49
|
+
|
|
50
|
+
from openscvx.config import Config
|
|
51
|
+
|
|
52
|
+
from .ptr_solver import PTRSolver, PTRSolveResult
|
|
53
|
+
|
|
54
|
+
try:
|
|
55
|
+
import jax.numpy as jnp
|
|
56
|
+
import qpax
|
|
57
|
+
|
|
58
|
+
_QPAX_AVAILABLE = True
|
|
59
|
+
except ImportError: # pragma: no cover — exercised by the install-error test
|
|
60
|
+
qpax = None
|
|
61
|
+
jnp = None
|
|
62
|
+
_QPAX_AVAILABLE = False
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
if TYPE_CHECKING:
|
|
66
|
+
from openscvx.lowered import LoweredProblem
|
|
67
|
+
from openscvx.lowered.jax_constraints import LoweredJaxConstraints
|
|
68
|
+
from openscvx.lowered.unified import UnifiedControl, UnifiedState
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
# Tiny diagonal regularization added to Q on rows whose cost is purely
|
|
72
|
+
# linear (nu, nu_vb, slacks, x, u). qpax's PDIP path Choleskys ``Q +
|
|
73
|
+
# Gᵀ diag(z/s) G``; keeping every diagonal entry strictly positive avoids a
|
|
74
|
+
# rank-deficient factorization the very first iteration and costs nothing in
|
|
75
|
+
# the final solution.
|
|
76
|
+
_Q_DIAG_EPS = 1e-10
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
@dataclass
|
|
80
|
+
class _QPLayout:
|
|
81
|
+
"""Static index layout for the flat decision vector ``z``.
|
|
82
|
+
|
|
83
|
+
The PTR variables and their slack reformulations sit in one concatenated
|
|
84
|
+
1-D vector. Slices are computed once at :meth:`QPAXPTRSolver.create_variables`
|
|
85
|
+
time and reused on every solve.
|
|
86
|
+
"""
|
|
87
|
+
|
|
88
|
+
N: int
|
|
89
|
+
n_x: int
|
|
90
|
+
n_u: int
|
|
91
|
+
n_nodal: int
|
|
92
|
+
|
|
93
|
+
sl_x: slice = field(init=False)
|
|
94
|
+
sl_u: slice = field(init=False)
|
|
95
|
+
sl_dx: slice = field(init=False)
|
|
96
|
+
sl_du: slice = field(init=False)
|
|
97
|
+
sl_nu: slice = field(init=False)
|
|
98
|
+
sl_nu_vb: List[slice] = field(init=False)
|
|
99
|
+
sl_s_abs: slice = field(init=False)
|
|
100
|
+
sl_s_pos: List[slice] = field(init=False)
|
|
101
|
+
n_z: int = field(init=False)
|
|
102
|
+
|
|
103
|
+
def __post_init__(self):
|
|
104
|
+
N, n_x, n_u, C = self.N, self.n_x, self.n_u, self.n_nodal
|
|
105
|
+
cursor = 0
|
|
106
|
+
|
|
107
|
+
def take(width: int) -> slice:
|
|
108
|
+
nonlocal cursor
|
|
109
|
+
s = slice(cursor, cursor + width)
|
|
110
|
+
cursor += width
|
|
111
|
+
return s
|
|
112
|
+
|
|
113
|
+
self.sl_x = take(N * n_x)
|
|
114
|
+
self.sl_u = take(N * n_u)
|
|
115
|
+
self.sl_dx = take(N * n_x)
|
|
116
|
+
self.sl_du = take(N * n_u)
|
|
117
|
+
self.sl_nu = take((N - 1) * n_x)
|
|
118
|
+
self.sl_nu_vb = [take(N) for _ in range(C)]
|
|
119
|
+
self.sl_s_abs = take((N - 1) * n_x)
|
|
120
|
+
self.sl_s_pos = [take(N) for _ in range(C)]
|
|
121
|
+
self.n_z = cursor
|
|
122
|
+
|
|
123
|
+
# -- helpers for clarity at call sites --
|
|
124
|
+
def x_idx(self, k: int, j: int) -> int:
|
|
125
|
+
return self.sl_x.start + k * self.n_x + j
|
|
126
|
+
|
|
127
|
+
def u_idx(self, k: int, j: int) -> int:
|
|
128
|
+
return self.sl_u.start + k * self.n_u + j
|
|
129
|
+
|
|
130
|
+
def dx_idx(self, k: int, j: int) -> int:
|
|
131
|
+
return self.sl_dx.start + k * self.n_x + j
|
|
132
|
+
|
|
133
|
+
def du_idx(self, k: int, j: int) -> int:
|
|
134
|
+
return self.sl_du.start + k * self.n_u + j
|
|
135
|
+
|
|
136
|
+
def nu_idx(self, k: int, j: int) -> int:
|
|
137
|
+
"""``k`` ∈ [0, N-2], indexing into ν[1..N-1] in the CVXPy notation."""
|
|
138
|
+
return self.sl_nu.start + k * self.n_x + j
|
|
139
|
+
|
|
140
|
+
def nu_vb_idx(self, c: int, k: int) -> int:
|
|
141
|
+
return self.sl_nu_vb[c].start + k
|
|
142
|
+
|
|
143
|
+
def s_abs_idx(self, k: int, j: int) -> int:
|
|
144
|
+
return self.sl_s_abs.start + k * self.n_x + j
|
|
145
|
+
|
|
146
|
+
def s_pos_idx(self, c: int, k: int) -> int:
|
|
147
|
+
return self.sl_s_pos[c].start + k
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
class QPAXPTRSolver(PTRSolver):
|
|
151
|
+
"""JAX-native QP backend for the PTR convex subproblem.
|
|
152
|
+
|
|
153
|
+
Assembles each SCP subproblem as a flat ``(Q, q, A, b, G, h)`` and
|
|
154
|
+
dispatches to ``qpax.solve_qp``. See the module docstring for the L1 /
|
|
155
|
+
positive-part slack reformulation and the rationale behind the design.
|
|
156
|
+
|
|
157
|
+
Scope:
|
|
158
|
+
Supported — state/control box, dynamics linearization, boundary
|
|
159
|
+
``Fix``, uniform time grid, linearized nodal nonconvex, CTCS
|
|
160
|
+
LICQ-style rows.
|
|
161
|
+
|
|
162
|
+
Not supported — user ``.convex()`` constraints, cross-node
|
|
163
|
+
constraints, and impulsive controls. Each raises
|
|
164
|
+
:class:`NotImplementedError` with a "use
|
|
165
|
+
:class:`openscvx.solvers.cvxpy_ptr_solver.CVXPyPTRSolver`" pointer.
|
|
166
|
+
Cross-node and impulsive support may be added in the future;
|
|
167
|
+
``.convex()`` would need a second-order-cone solver and is unlikely
|
|
168
|
+
to land here directly.
|
|
169
|
+
|
|
170
|
+
Differentiability hook for future work:
|
|
171
|
+
``qpax.solve_qp_primal`` is differentiable via ``jax.custom_vjp``.
|
|
172
|
+
Swapping it in here (once the surrounding pipeline stays in JIT) is
|
|
173
|
+
what lets ``jax.grad`` / ``jax.vmap`` reach through a full SCvx solve.
|
|
174
|
+
|
|
175
|
+
Args:
|
|
176
|
+
solver_args: Keyword arguments forwarded to ``qpax.solve_qp``. Useful
|
|
177
|
+
keys include ``solver_tol`` (default ``1e-5``), ``max_iter``
|
|
178
|
+
(default ``30``), ``linear_solver``, and ``backend`` (``"i"`` for
|
|
179
|
+
implicit retraction-manifold PDIP — qpax's default — or ``"e"``
|
|
180
|
+
for the explicit predictor-corrector path).
|
|
181
|
+
|
|
182
|
+
Attributes:
|
|
183
|
+
layout: ``_QPLayout`` describing the flat decision-vector slot ranges.
|
|
184
|
+
Populated by :meth:`create_variables`.
|
|
185
|
+
"""
|
|
186
|
+
|
|
187
|
+
def __init__(self, solver_args: Optional[dict] = None):
|
|
188
|
+
if not _QPAX_AVAILABLE:
|
|
189
|
+
raise ImportError(
|
|
190
|
+
"QPAXPTRSolver requires the `qpax` package. "
|
|
191
|
+
"Install it with: pip install openscvx[qpax]"
|
|
192
|
+
)
|
|
193
|
+
|
|
194
|
+
self.solver_args = dict(solver_args) if solver_args else {}
|
|
195
|
+
|
|
196
|
+
# Populated by create_variables / initialize.
|
|
197
|
+
self.layout: Optional[_QPLayout] = None
|
|
198
|
+
self._S_x: Optional[np.ndarray] = None
|
|
199
|
+
self._c_x: Optional[np.ndarray] = None
|
|
200
|
+
self._S_u: Optional[np.ndarray] = None
|
|
201
|
+
self._c_u: Optional[np.ndarray] = None
|
|
202
|
+
self._inv_S_x_diag: Optional[np.ndarray] = None
|
|
203
|
+
self._inv_S_u_diag: Optional[np.ndarray] = None
|
|
204
|
+
self._S_x_diag: Optional[np.ndarray] = None
|
|
205
|
+
self._S_u_diag: Optional[np.ndarray] = None
|
|
206
|
+
self._settings: Optional[Config] = None
|
|
207
|
+
self._jax_constraints: Optional["LoweredJaxConstraints"] = None
|
|
208
|
+
|
|
209
|
+
# Per-iteration data, set by update_* methods.
|
|
210
|
+
self._dyn: dict = {}
|
|
211
|
+
self._cons: dict = {}
|
|
212
|
+
self._pen: dict = {}
|
|
213
|
+
self._x_init: Optional[np.ndarray] = None
|
|
214
|
+
self._x_term: Optional[np.ndarray] = None
|
|
215
|
+
|
|
216
|
+
# Last solve diagnostics (populated by solve()).
|
|
217
|
+
self._last_iters: Optional[int] = None
|
|
218
|
+
self._last_converged: Optional[bool] = None
|
|
219
|
+
|
|
220
|
+
# ------------------------------------------------------------------
|
|
221
|
+
# Setup
|
|
222
|
+
# ------------------------------------------------------------------
|
|
223
|
+
|
|
224
|
+
def create_variables(
|
|
225
|
+
self,
|
|
226
|
+
N: int,
|
|
227
|
+
x_unified: "UnifiedState",
|
|
228
|
+
u_unified: "UnifiedControl",
|
|
229
|
+
jax_constraints: "LoweredJaxConstraints",
|
|
230
|
+
dynamics_sparsity: Optional[tuple] = None,
|
|
231
|
+
constraint_sparsity: Optional[list] = None,
|
|
232
|
+
) -> None:
|
|
233
|
+
"""Compute scaling matrices and the static QP decision-vector layout.
|
|
234
|
+
|
|
235
|
+
Sparsity hints are accepted for interface symmetry with
|
|
236
|
+
:class:`openscvx.solvers.cvxpy_ptr_solver.CVXPyPTRSolver` but ignored
|
|
237
|
+
— QPAX consumes dense arrays.
|
|
238
|
+
"""
|
|
239
|
+
del dynamics_sparsity, constraint_sparsity # QPAX consumes dense arrays
|
|
240
|
+
|
|
241
|
+
n_x = len(x_unified.max)
|
|
242
|
+
n_u = len(u_unified.max)
|
|
243
|
+
|
|
244
|
+
# Reject impulsive at create_variables — the layout assumes no impulsive
|
|
245
|
+
# coupling. The plan punts impulsive support to a follow-up PR.
|
|
246
|
+
slice_imp = u_unified.slice_impulsive
|
|
247
|
+
if slice_imp.stop > slice_imp.start:
|
|
248
|
+
raise NotImplementedError(
|
|
249
|
+
"QPAXPTRSolver does not support impulsive controls "
|
|
250
|
+
f"(u.slice_impulsive = {slice_imp!r}). "
|
|
251
|
+
"Use CVXPyPTRSolver for problems with impulsive dynamics."
|
|
252
|
+
)
|
|
253
|
+
|
|
254
|
+
S_x, c_x = self._scaling(x_unified)
|
|
255
|
+
S_u, c_u = self._scaling(u_unified)
|
|
256
|
+
|
|
257
|
+
self._S_x = S_x
|
|
258
|
+
self._c_x = c_x
|
|
259
|
+
self._S_u = S_u
|
|
260
|
+
self._c_u = c_u
|
|
261
|
+
# S_x / S_u are diagonal — keep the diagonals around for fast scalar
|
|
262
|
+
# arithmetic in the assembly loops.
|
|
263
|
+
self._S_x_diag = np.diag(S_x)
|
|
264
|
+
self._S_u_diag = np.diag(S_u)
|
|
265
|
+
self._inv_S_x_diag = 1.0 / self._S_x_diag
|
|
266
|
+
self._inv_S_u_diag = 1.0 / self._S_u_diag
|
|
267
|
+
|
|
268
|
+
self.layout = _QPLayout(N=N, n_x=n_x, n_u=n_u, n_nodal=len(jax_constraints.nodal))
|
|
269
|
+
self._jax_constraints = jax_constraints
|
|
270
|
+
|
|
271
|
+
def initialize(self, lowered: "LoweredProblem", settings: "Config") -> None:
|
|
272
|
+
"""Validate the constraint subset QPAX supports and stash settings.
|
|
273
|
+
|
|
274
|
+
Cross-node constraints and impulsive controls each raise
|
|
275
|
+
:class:`NotImplementedError` with a pointer to
|
|
276
|
+
:class:`CVXPyPTRSolver`; either may gain QP-side support here in the
|
|
277
|
+
future. User ``.convex()`` constraints are already rejected upstream
|
|
278
|
+
by the default :meth:`ConvexSolver.lower_convex_constraints`.
|
|
279
|
+
"""
|
|
280
|
+
if self.layout is None:
|
|
281
|
+
raise RuntimeError(
|
|
282
|
+
"QPAXPTRSolver.initialize() called before create_variables(). "
|
|
283
|
+
"Call create_variables() first."
|
|
284
|
+
)
|
|
285
|
+
|
|
286
|
+
# User .convex() constraints are rejected upstream — the default
|
|
287
|
+
# ConvexSolver.lower_convex_constraints raises before we get here.
|
|
288
|
+
if lowered.jax_constraints.cross_node:
|
|
289
|
+
raise NotImplementedError(
|
|
290
|
+
"QPAXPTRSolver does not yet support cross-node constraints "
|
|
291
|
+
f"({len(lowered.jax_constraints.cross_node)} defined). "
|
|
292
|
+
"Use CVXPyPTRSolver."
|
|
293
|
+
)
|
|
294
|
+
slice_imp = settings.sim.u.slice_impulsive
|
|
295
|
+
if slice_imp.stop > slice_imp.start:
|
|
296
|
+
raise NotImplementedError(
|
|
297
|
+
"QPAXPTRSolver does not support impulsive controls. Use CVXPyPTRSolver."
|
|
298
|
+
)
|
|
299
|
+
|
|
300
|
+
self._settings = settings
|
|
301
|
+
|
|
302
|
+
# ------------------------------------------------------------------
|
|
303
|
+
# Per-iteration update hooks
|
|
304
|
+
# ------------------------------------------------------------------
|
|
305
|
+
|
|
306
|
+
def update_dynamics_linearization(
|
|
307
|
+
self,
|
|
308
|
+
x_bar: np.ndarray,
|
|
309
|
+
u_bar: np.ndarray,
|
|
310
|
+
A_d: np.ndarray,
|
|
311
|
+
B_d: np.ndarray,
|
|
312
|
+
C_d: np.ndarray,
|
|
313
|
+
x_prop: np.ndarray,
|
|
314
|
+
x_prop_plus: np.ndarray | None = None,
|
|
315
|
+
D_d: np.ndarray | None = None,
|
|
316
|
+
E_d: np.ndarray | None = None,
|
|
317
|
+
) -> None:
|
|
318
|
+
# Impulsive controls are rejected at initialize(); D_d / E_d /
|
|
319
|
+
# x_prop_plus only ever arrive here because the algorithm passes
|
|
320
|
+
# them unconditionally. Drop them silently. Future impulsive
|
|
321
|
+
# support would consume these to add block-coupled equality rows
|
|
322
|
+
# at the impulsive nodes.
|
|
323
|
+
del x_prop_plus, D_d, E_d
|
|
324
|
+
self._dyn = {
|
|
325
|
+
"x_bar": np.asarray(x_bar, dtype=float),
|
|
326
|
+
"u_bar": np.asarray(u_bar, dtype=float),
|
|
327
|
+
"A_d": np.asarray(A_d, dtype=float),
|
|
328
|
+
"B_d": np.asarray(B_d, dtype=float),
|
|
329
|
+
"C_d": np.asarray(C_d, dtype=float),
|
|
330
|
+
"x_prop": np.asarray(x_prop, dtype=float),
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
def update_constraint_linearizations(
|
|
334
|
+
self,
|
|
335
|
+
nodal: List[dict] = None,
|
|
336
|
+
cross_node: List[dict] = None,
|
|
337
|
+
) -> None:
|
|
338
|
+
if cross_node:
|
|
339
|
+
# Defensive — initialize() already rejected, but the algorithm
|
|
340
|
+
# may still pass an empty list.
|
|
341
|
+
raise NotImplementedError(
|
|
342
|
+
"QPAXPTRSolver received cross-node linearization data; "
|
|
343
|
+
"cross-node constraints are not supported."
|
|
344
|
+
)
|
|
345
|
+
self._cons = {
|
|
346
|
+
"nodal": [
|
|
347
|
+
{
|
|
348
|
+
"g": np.asarray(d["g"], dtype=float),
|
|
349
|
+
"grad_g_x": np.asarray(d["grad_g_x"], dtype=float),
|
|
350
|
+
"grad_g_u": np.asarray(d["grad_g_u"], dtype=float),
|
|
351
|
+
}
|
|
352
|
+
for d in (nodal or [])
|
|
353
|
+
],
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
def update_penalties(
|
|
357
|
+
self,
|
|
358
|
+
lam_prox: np.ndarray,
|
|
359
|
+
lam_cost: Union[float, np.ndarray],
|
|
360
|
+
lam_vc: np.ndarray,
|
|
361
|
+
lam_vb_nodal: np.ndarray,
|
|
362
|
+
lam_vb_cross: np.ndarray,
|
|
363
|
+
) -> None:
|
|
364
|
+
del lam_vb_cross # cross-node constraints rejected at initialize()
|
|
365
|
+
self._pen = {
|
|
366
|
+
"lam_prox": np.asarray(lam_prox, dtype=float),
|
|
367
|
+
"lam_cost": np.asarray(lam_cost, dtype=float),
|
|
368
|
+
"lam_vc": np.asarray(lam_vc, dtype=float),
|
|
369
|
+
"lam_vb_nodal": np.asarray(lam_vb_nodal, dtype=float),
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
def update_boundary_conditions(
|
|
373
|
+
self,
|
|
374
|
+
x_init: np.ndarray = None,
|
|
375
|
+
x_term: np.ndarray = None,
|
|
376
|
+
) -> None:
|
|
377
|
+
if x_init is not None:
|
|
378
|
+
self._x_init = np.asarray(x_init, dtype=float)
|
|
379
|
+
if x_term is not None:
|
|
380
|
+
self._x_term = np.asarray(x_term, dtype=float)
|
|
381
|
+
|
|
382
|
+
# ------------------------------------------------------------------
|
|
383
|
+
# Assembly
|
|
384
|
+
# ------------------------------------------------------------------
|
|
385
|
+
|
|
386
|
+
def _assemble_qp(
|
|
387
|
+
self,
|
|
388
|
+
) -> Tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray, np.ndarray, np.ndarray]:
|
|
389
|
+
"""Build ``(Q, q, A, b, G, h)`` from the stored linearization data.
|
|
390
|
+
|
|
391
|
+
The decision vector layout, the per-row recipes, and the variable
|
|
392
|
+
scaling all mirror :meth:`CVXPyPTRSolver.constraints` so that the two
|
|
393
|
+
backends solve the same convex subproblem up to slack reformulation.
|
|
394
|
+
"""
|
|
395
|
+
if not (self._dyn and self._cons and self._pen):
|
|
396
|
+
raise RuntimeError(
|
|
397
|
+
"QPAXPTRSolver.solve() requires update_dynamics_linearization, "
|
|
398
|
+
"update_constraint_linearizations, and update_penalties to all "
|
|
399
|
+
"have been called this iteration."
|
|
400
|
+
)
|
|
401
|
+
|
|
402
|
+
L = self.layout
|
|
403
|
+
settings = self._settings
|
|
404
|
+
N, n_x, n_u = L.N, L.n_x, L.n_u
|
|
405
|
+
inv_S_x = self._inv_S_x_diag # diagonals
|
|
406
|
+
inv_S_u = self._inv_S_u_diag
|
|
407
|
+
S_x = self._S_x_diag
|
|
408
|
+
c_x = self._c_x
|
|
409
|
+
c_u = self._c_u
|
|
410
|
+
|
|
411
|
+
lam_prox = self._pen["lam_prox"] # (N, n_x + n_u)
|
|
412
|
+
lam_cost = self._pen["lam_cost"] # scalar or (n_x,)
|
|
413
|
+
lam_vc = self._pen["lam_vc"] # (N-1, n_x)
|
|
414
|
+
lam_vb_nodal = self._pen["lam_vb_nodal"] # (N, max(C, 1))
|
|
415
|
+
|
|
416
|
+
# ---------- cost ----------
|
|
417
|
+
q_diag = np.full(L.n_z, _Q_DIAG_EPS, dtype=float)
|
|
418
|
+
q_vec = np.zeros(L.n_z, dtype=float)
|
|
419
|
+
|
|
420
|
+
# Trust-region: ½ zᵀ Q z with Q_diag = 2 · lam_prox on dx / du slots
|
|
421
|
+
# reproduces lam_prox · (dx² + du²) from the CVXPy cost.
|
|
422
|
+
for k in range(N):
|
|
423
|
+
for j in range(n_x):
|
|
424
|
+
q_diag[L.dx_idx(k, j)] = 2.0 * lam_prox[k, j] + _Q_DIAG_EPS
|
|
425
|
+
for j in range(n_u):
|
|
426
|
+
q_diag[L.du_idx(k, j)] = 2.0 * lam_prox[k, n_x + j] + _Q_DIAG_EPS
|
|
427
|
+
|
|
428
|
+
# Boundary cost terms (Minimize / Maximize) — operate on scaled x for
|
|
429
|
+
# the same numerical conditioning CVXPyPTRSolver gets.
|
|
430
|
+
lam_cost_arr = np.broadcast_to(lam_cost, (settings.sim.n_states,))
|
|
431
|
+
for i in range(settings.sim.true_state_slice.start, settings.sim.true_state_slice.stop):
|
|
432
|
+
init_t = settings.sim.x.initial_type[i]
|
|
433
|
+
final_t = settings.sim.x.final_type[i]
|
|
434
|
+
if init_t == "Minimize":
|
|
435
|
+
q_vec[L.x_idx(0, i)] += lam_cost_arr[i]
|
|
436
|
+
elif init_t == "Maximize":
|
|
437
|
+
q_vec[L.x_idx(0, i)] -= lam_cost_arr[i]
|
|
438
|
+
if final_t == "Minimize":
|
|
439
|
+
q_vec[L.x_idx(N - 1, i)] += lam_cost_arr[i]
|
|
440
|
+
elif final_t == "Maximize":
|
|
441
|
+
q_vec[L.x_idx(N - 1, i)] -= lam_cost_arr[i]
|
|
442
|
+
|
|
443
|
+
# L1 penalty on |nu| → linear cost on s_abs
|
|
444
|
+
for k in range(N - 1):
|
|
445
|
+
for j in range(n_x):
|
|
446
|
+
q_vec[L.s_abs_idx(k, j)] += lam_vc[k, j]
|
|
447
|
+
|
|
448
|
+
# Positive-part penalty on nu_vb → linear cost on s_pos
|
|
449
|
+
for c_idx in range(L.n_nodal):
|
|
450
|
+
for k in range(N):
|
|
451
|
+
q_vec[L.s_pos_idx(c_idx, k)] += lam_vb_nodal[k, c_idx]
|
|
452
|
+
|
|
453
|
+
Q = np.diag(q_diag)
|
|
454
|
+
|
|
455
|
+
# ---------- equality rows (A z = b) ----------
|
|
456
|
+
A_rows: List[np.ndarray] = []
|
|
457
|
+
b_rows: List[float] = []
|
|
458
|
+
|
|
459
|
+
# Linearized nodal constraints: g + grad_x·dx + grad_u·du = nu_vb
|
|
460
|
+
for c_idx, constraint in enumerate(self._jax_constraints.nodal):
|
|
461
|
+
data = self._cons["nodal"][c_idx]
|
|
462
|
+
g = data["g"] # (N,)
|
|
463
|
+
grad_x = data["grad_g_x"] # (N, n_x)
|
|
464
|
+
grad_u = data["grad_g_u"] # (N, n_u)
|
|
465
|
+
for node in constraint.nodes:
|
|
466
|
+
row = np.zeros(L.n_z, dtype=float)
|
|
467
|
+
for j in range(n_x):
|
|
468
|
+
row[L.dx_idx(node, j)] = grad_x[node, j]
|
|
469
|
+
for j in range(n_u):
|
|
470
|
+
row[L.du_idx(node, j)] = grad_u[node, j]
|
|
471
|
+
row[L.nu_vb_idx(c_idx, node)] = -1.0
|
|
472
|
+
A_rows.append(row)
|
|
473
|
+
b_rows.append(-g[node])
|
|
474
|
+
|
|
475
|
+
# Boundary conditions (Fix branch only; impulsive Fix raised earlier).
|
|
476
|
+
for i in range(settings.sim.true_state_slice.start, settings.sim.true_state_slice.stop):
|
|
477
|
+
if settings.sim.x.initial_type[i] == "Fix":
|
|
478
|
+
if self._x_init is None:
|
|
479
|
+
raise RuntimeError(
|
|
480
|
+
f"Fix initial condition on state {i} requires x_init; "
|
|
481
|
+
"call update_boundary_conditions() before solve()."
|
|
482
|
+
)
|
|
483
|
+
row = np.zeros(L.n_z, dtype=float)
|
|
484
|
+
row[L.x_idx(0, i)] = S_x[i]
|
|
485
|
+
A_rows.append(row)
|
|
486
|
+
b_rows.append(self._x_init[i] - c_x[i])
|
|
487
|
+
if settings.sim.x.final_type[i] == "Fix":
|
|
488
|
+
if self._x_term is None:
|
|
489
|
+
raise RuntimeError(
|
|
490
|
+
f"Fix final condition on state {i} requires x_term; "
|
|
491
|
+
"call update_boundary_conditions() before solve()."
|
|
492
|
+
)
|
|
493
|
+
row = np.zeros(L.n_z, dtype=float)
|
|
494
|
+
row[L.x_idx(N - 1, i)] = S_x[i]
|
|
495
|
+
A_rows.append(row)
|
|
496
|
+
b_rows.append(self._x_term[i] - c_x[i])
|
|
497
|
+
|
|
498
|
+
# Uniform time-grid: scaled u along the time-dilation slice is equal
|
|
499
|
+
# at consecutive nodes. The CVXPy formulation premultiplies by
|
|
500
|
+
# ``inv_S_u`` on both sides; that cancels, leaving u[k] = u[k-1].
|
|
501
|
+
if settings.sim._uniform_time_grid:
|
|
502
|
+
td = settings.sim.time_dilation_slice
|
|
503
|
+
for k in range(1, N):
|
|
504
|
+
for j in range(td.start, td.stop):
|
|
505
|
+
row = np.zeros(L.n_z, dtype=float)
|
|
506
|
+
row[L.u_idx(k, j)] = 1.0
|
|
507
|
+
row[L.u_idx(k - 1, j)] = -1.0
|
|
508
|
+
A_rows.append(row)
|
|
509
|
+
b_rows.append(0.0)
|
|
510
|
+
|
|
511
|
+
# State / control error definitions: dx[k] = x[k] - inv_S_x (x_bar[k] - c_x).
|
|
512
|
+
# Mirrors CVXPyPTRSolver.constraints — same scaling, same sign.
|
|
513
|
+
x_bar = self._dyn["x_bar"]
|
|
514
|
+
u_bar = self._dyn["u_bar"]
|
|
515
|
+
for k in range(N):
|
|
516
|
+
for j in range(n_x):
|
|
517
|
+
row = np.zeros(L.n_z, dtype=float)
|
|
518
|
+
row[L.x_idx(k, j)] = 1.0
|
|
519
|
+
row[L.dx_idx(k, j)] = -1.0
|
|
520
|
+
A_rows.append(row)
|
|
521
|
+
b_rows.append(inv_S_x[j] * (x_bar[k, j] - c_x[j]))
|
|
522
|
+
for j in range(n_u):
|
|
523
|
+
row = np.zeros(L.n_z, dtype=float)
|
|
524
|
+
row[L.u_idx(k, j)] = 1.0
|
|
525
|
+
row[L.du_idx(k, j)] = -1.0
|
|
526
|
+
A_rows.append(row)
|
|
527
|
+
b_rows.append(inv_S_u[j] * (u_bar[k, j] - c_u[j]))
|
|
528
|
+
|
|
529
|
+
# Dynamics (continuous PTR branch, FOH-style coupling):
|
|
530
|
+
# x[k] - inv_S_x A_d S_x dx[k-1] - inv_S_x B_d S_u du[k-1]
|
|
531
|
+
# - inv_S_x C_d S_u du[k] - nu[k-1] = inv_S_x (x_prop[k-1] - c_x)
|
|
532
|
+
A_d = self._dyn["A_d"] # (N-1, n_x, n_x)
|
|
533
|
+
B_d = self._dyn["B_d"] # (N-1, n_x, n_u)
|
|
534
|
+
C_d = self._dyn["C_d"] # (N-1, n_x, n_u)
|
|
535
|
+
x_prop = self._dyn["x_prop"] # (N-1, n_x) — propagated from k-1 → k
|
|
536
|
+
for k in range(1, N):
|
|
537
|
+
kp = k - 1 # previous-segment index
|
|
538
|
+
# Pre-scaled blocks: inv_S_x[i, i] · A_d[kp, i, :] · S_x[:, j] · dx[kp, j]
|
|
539
|
+
A_block = (inv_S_x[:, None] * A_d[kp]) * S_x[None, :]
|
|
540
|
+
B_block = (inv_S_x[:, None] * B_d[kp]) * self._S_u_diag[None, :]
|
|
541
|
+
C_block = (inv_S_x[:, None] * C_d[kp]) * self._S_u_diag[None, :]
|
|
542
|
+
rhs = inv_S_x * (x_prop[kp] - c_x)
|
|
543
|
+
for i in range(n_x):
|
|
544
|
+
row = np.zeros(L.n_z, dtype=float)
|
|
545
|
+
row[L.x_idx(k, i)] = 1.0
|
|
546
|
+
for j in range(n_x):
|
|
547
|
+
row[L.dx_idx(kp, j)] = -A_block[i, j]
|
|
548
|
+
for j in range(n_u):
|
|
549
|
+
row[L.du_idx(kp, j)] = -B_block[i, j]
|
|
550
|
+
row[L.du_idx(k, j)] = -C_block[i, j]
|
|
551
|
+
row[L.nu_idx(kp, i)] = -1.0
|
|
552
|
+
A_rows.append(row)
|
|
553
|
+
b_rows.append(rhs[i])
|
|
554
|
+
|
|
555
|
+
# ---------- inequality rows (G z ≤ h) ----------
|
|
556
|
+
G_rows: List[np.ndarray] = []
|
|
557
|
+
h_rows: List[float] = []
|
|
558
|
+
|
|
559
|
+
# Control / state box constraints — in scaled coords these reduce to
|
|
560
|
+
# simple bounds on x[k, j] and u[k, j].
|
|
561
|
+
u_max_scaled = inv_S_u * (np.asarray(settings.sim.u.max, dtype=float) - c_u)
|
|
562
|
+
u_min_scaled = inv_S_u * (np.asarray(settings.sim.u.min, dtype=float) - c_u)
|
|
563
|
+
x_max_scaled = inv_S_x * (np.asarray(settings.sim.x.max, dtype=float) - c_x)
|
|
564
|
+
x_min_scaled = inv_S_x * (np.asarray(settings.sim.x.min, dtype=float) - c_x)
|
|
565
|
+
for k in range(N):
|
|
566
|
+
for j in range(n_u):
|
|
567
|
+
row = np.zeros(L.n_z, dtype=float)
|
|
568
|
+
row[L.u_idx(k, j)] = 1.0
|
|
569
|
+
G_rows.append(row)
|
|
570
|
+
h_rows.append(u_max_scaled[j])
|
|
571
|
+
row = np.zeros(L.n_z, dtype=float)
|
|
572
|
+
row[L.u_idx(k, j)] = -1.0
|
|
573
|
+
G_rows.append(row)
|
|
574
|
+
h_rows.append(-u_min_scaled[j])
|
|
575
|
+
for j in range(n_x):
|
|
576
|
+
row = np.zeros(L.n_z, dtype=float)
|
|
577
|
+
row[L.x_idx(k, j)] = 1.0
|
|
578
|
+
G_rows.append(row)
|
|
579
|
+
h_rows.append(x_max_scaled[j])
|
|
580
|
+
row = np.zeros(L.n_z, dtype=float)
|
|
581
|
+
row[L.x_idx(k, j)] = -1.0
|
|
582
|
+
G_rows.append(row)
|
|
583
|
+
h_rows.append(-x_min_scaled[j])
|
|
584
|
+
|
|
585
|
+
# CTCS LICQ-style rows: for each CTCS group (one augmented-state
|
|
586
|
+
# index per group), enforce |x[i][idx] - x[i-1][idx]| ≤ x.max[idx]
|
|
587
|
+
# over the group's node interval, plus x[0][idx] = 0 (in unscaled
|
|
588
|
+
# coords). Mirrors CVXPyPTRSolver's CTCS block.
|
|
589
|
+
for idx, nodes in zip(
|
|
590
|
+
np.arange(settings.sim.ctcs_slice.start, settings.sim.ctcs_slice.stop),
|
|
591
|
+
settings.sim.ctcs_node_intervals,
|
|
592
|
+
):
|
|
593
|
+
start_idx = 1 if nodes[0] == 0 else nodes[0]
|
|
594
|
+
x_max_unscaled = float(settings.sim.x.max[idx])
|
|
595
|
+
# In scaled coords: x_nonscaled[i][idx] - x_nonscaled[i-1][idx]
|
|
596
|
+
# = S_x[idx] * (x[i][idx] - x[i-1][idx]).
|
|
597
|
+
scale = S_x[idx]
|
|
598
|
+
for i in range(start_idx, nodes[1]):
|
|
599
|
+
row = np.zeros(L.n_z, dtype=float)
|
|
600
|
+
row[L.x_idx(i, idx)] = scale
|
|
601
|
+
row[L.x_idx(i - 1, idx)] = -scale
|
|
602
|
+
G_rows.append(row)
|
|
603
|
+
h_rows.append(x_max_unscaled)
|
|
604
|
+
row = np.zeros(L.n_z, dtype=float)
|
|
605
|
+
row[L.x_idx(i, idx)] = -scale
|
|
606
|
+
row[L.x_idx(i - 1, idx)] = scale
|
|
607
|
+
G_rows.append(row)
|
|
608
|
+
h_rows.append(x_max_unscaled)
|
|
609
|
+
# x_nonscaled[0][idx] = 0 -> S_x[idx] * x[0][idx] = -c_x[idx]
|
|
610
|
+
row = np.zeros(L.n_z, dtype=float)
|
|
611
|
+
row[L.x_idx(0, idx)] = scale
|
|
612
|
+
A_rows.append(row)
|
|
613
|
+
b_rows.append(-c_x[idx])
|
|
614
|
+
|
|
615
|
+
A_mat = np.asarray(A_rows, dtype=float) if A_rows else np.zeros((0, L.n_z))
|
|
616
|
+
b_vec = np.asarray(b_rows, dtype=float) if b_rows else np.zeros(0)
|
|
617
|
+
|
|
618
|
+
# L1 reformulation of |nu|: nu - s_abs ≤ 0, -nu - s_abs ≤ 0.
|
|
619
|
+
# Both together imply s_abs ≥ |nu| ≥ 0, so an explicit nonneg row
|
|
620
|
+
# would be redundant.
|
|
621
|
+
for k in range(N - 1):
|
|
622
|
+
for j in range(n_x):
|
|
623
|
+
row = np.zeros(L.n_z, dtype=float)
|
|
624
|
+
row[L.nu_idx(k, j)] = 1.0
|
|
625
|
+
row[L.s_abs_idx(k, j)] = -1.0
|
|
626
|
+
G_rows.append(row)
|
|
627
|
+
h_rows.append(0.0)
|
|
628
|
+
row = np.zeros(L.n_z, dtype=float)
|
|
629
|
+
row[L.nu_idx(k, j)] = -1.0
|
|
630
|
+
row[L.s_abs_idx(k, j)] = -1.0
|
|
631
|
+
G_rows.append(row)
|
|
632
|
+
h_rows.append(0.0)
|
|
633
|
+
|
|
634
|
+
# Positive-part reformulation: pos(nu_vb) needs s ≥ nu_vb AND s ≥ 0
|
|
635
|
+
# — the second is *not* implied by the first (unlike L1).
|
|
636
|
+
for c_idx in range(L.n_nodal):
|
|
637
|
+
for k in range(N):
|
|
638
|
+
row = np.zeros(L.n_z, dtype=float)
|
|
639
|
+
row[L.nu_vb_idx(c_idx, k)] = 1.0
|
|
640
|
+
row[L.s_pos_idx(c_idx, k)] = -1.0
|
|
641
|
+
G_rows.append(row)
|
|
642
|
+
h_rows.append(0.0)
|
|
643
|
+
row = np.zeros(L.n_z, dtype=float)
|
|
644
|
+
row[L.s_pos_idx(c_idx, k)] = -1.0
|
|
645
|
+
G_rows.append(row)
|
|
646
|
+
h_rows.append(0.0)
|
|
647
|
+
|
|
648
|
+
G_mat = np.asarray(G_rows, dtype=float) if G_rows else np.zeros((0, L.n_z))
|
|
649
|
+
h_vec = np.asarray(h_rows, dtype=float) if h_rows else np.zeros(0)
|
|
650
|
+
|
|
651
|
+
return Q, q_vec, A_mat, b_vec, G_mat, h_vec
|
|
652
|
+
|
|
653
|
+
# ------------------------------------------------------------------
|
|
654
|
+
# Solve / unpack
|
|
655
|
+
# ------------------------------------------------------------------
|
|
656
|
+
|
|
657
|
+
def solve(self) -> PTRSolveResult:
|
|
658
|
+
Q, q, A, b, G, h = self._assemble_qp()
|
|
659
|
+
|
|
660
|
+
Q_j = jnp.asarray(Q)
|
|
661
|
+
q_j = jnp.asarray(q)
|
|
662
|
+
A_j = jnp.asarray(A)
|
|
663
|
+
b_j = jnp.asarray(b)
|
|
664
|
+
G_j = jnp.asarray(G)
|
|
665
|
+
h_j = jnp.asarray(h)
|
|
666
|
+
|
|
667
|
+
z, _s, _z_dual, _y_dual, converged, iters = qpax.solve_qp(
|
|
668
|
+
Q_j, q_j, A_j, b_j, G_j, h_j, **self.solver_args
|
|
669
|
+
)
|
|
670
|
+
|
|
671
|
+
z = np.asarray(z)
|
|
672
|
+
self._last_iters = int(np.asarray(iters))
|
|
673
|
+
self._last_converged = bool(np.asarray(converged))
|
|
674
|
+
|
|
675
|
+
return self._unpack(z)
|
|
676
|
+
|
|
677
|
+
def _unpack(self, z: np.ndarray) -> PTRSolveResult:
|
|
678
|
+
"""Reverse the layout into the structured :class:`PTRSolveResult`."""
|
|
679
|
+
L = self.layout
|
|
680
|
+
N, n_x, n_u = L.N, L.n_x, L.n_u
|
|
681
|
+
|
|
682
|
+
# Scaled trajectories → physical units via the affine scaling.
|
|
683
|
+
x_scaled = z[L.sl_x].reshape(N, n_x)
|
|
684
|
+
u_scaled = z[L.sl_u].reshape(N, n_u)
|
|
685
|
+
x = x_scaled * self._S_x_diag[None, :] + self._c_x[None, :]
|
|
686
|
+
u = u_scaled * self._S_u_diag[None, :] + self._c_u[None, :]
|
|
687
|
+
|
|
688
|
+
nu = z[L.sl_nu].reshape(N - 1, n_x)
|
|
689
|
+
nu_vb = [np.asarray(z[sl]) for sl in L.sl_nu_vb]
|
|
690
|
+
nu_vb_cross: List[float] = []
|
|
691
|
+
|
|
692
|
+
cost = self._reconstruct_cost(z)
|
|
693
|
+
|
|
694
|
+
status = "optimal" if self._last_converged else "infeasible"
|
|
695
|
+
|
|
696
|
+
return PTRSolveResult(
|
|
697
|
+
x=x,
|
|
698
|
+
u=u,
|
|
699
|
+
nu=nu,
|
|
700
|
+
nu_vb=nu_vb,
|
|
701
|
+
nu_vb_cross=nu_vb_cross,
|
|
702
|
+
cost=cost,
|
|
703
|
+
status=status,
|
|
704
|
+
)
|
|
705
|
+
|
|
706
|
+
def _reconstruct_cost(self, z: np.ndarray) -> float:
|
|
707
|
+
"""Recompute the PTR objective value at ``z``.
|
|
708
|
+
|
|
709
|
+
Avoids holding onto the assembled ``Q`` / ``q`` (which can be
|
|
710
|
+
sizeable for large ``N``); the cost-defining quantities live on
|
|
711
|
+
``self._pen`` already.
|
|
712
|
+
"""
|
|
713
|
+
L = self.layout
|
|
714
|
+
settings = self._settings
|
|
715
|
+
N, n_x, n_u = L.N, L.n_x, L.n_u
|
|
716
|
+
lam_prox = self._pen["lam_prox"]
|
|
717
|
+
lam_cost_arr = np.broadcast_to(self._pen["lam_cost"], (settings.sim.n_states,))
|
|
718
|
+
lam_vc = self._pen["lam_vc"]
|
|
719
|
+
lam_vb_nodal = self._pen["lam_vb_nodal"]
|
|
720
|
+
|
|
721
|
+
dx = z[L.sl_dx].reshape(N, n_x)
|
|
722
|
+
du = z[L.sl_du].reshape(N, n_u)
|
|
723
|
+
x = z[L.sl_x].reshape(N, n_x)
|
|
724
|
+
s_abs = z[L.sl_s_abs].reshape(N - 1, n_x)
|
|
725
|
+
|
|
726
|
+
cost = float(np.sum(lam_prox[:, :n_x] * dx**2) + np.sum(lam_prox[:, n_x:] * du**2))
|
|
727
|
+
for i in range(settings.sim.true_state_slice.start, settings.sim.true_state_slice.stop):
|
|
728
|
+
init_t = settings.sim.x.initial_type[i]
|
|
729
|
+
final_t = settings.sim.x.final_type[i]
|
|
730
|
+
if init_t == "Minimize":
|
|
731
|
+
cost += float(lam_cost_arr[i] * x[0, i])
|
|
732
|
+
elif init_t == "Maximize":
|
|
733
|
+
cost -= float(lam_cost_arr[i] * x[0, i])
|
|
734
|
+
if final_t == "Minimize":
|
|
735
|
+
cost += float(lam_cost_arr[i] * x[-1, i])
|
|
736
|
+
elif final_t == "Maximize":
|
|
737
|
+
cost -= float(lam_cost_arr[i] * x[-1, i])
|
|
738
|
+
cost += float(np.sum(lam_vc * s_abs))
|
|
739
|
+
for c_idx in range(L.n_nodal):
|
|
740
|
+
s_pos = z[L.sl_s_pos[c_idx]]
|
|
741
|
+
cost += float(np.dot(lam_vb_nodal[:, c_idx], s_pos))
|
|
742
|
+
return cost
|
|
743
|
+
|
|
744
|
+
# ------------------------------------------------------------------
|
|
745
|
+
# Misc API
|
|
746
|
+
# ------------------------------------------------------------------
|
|
747
|
+
|
|
748
|
+
def get_stats(self) -> dict:
|
|
749
|
+
"""QP dimensions for the diagnostics box.
|
|
750
|
+
|
|
751
|
+
``n_parameters`` is reported as zero — QPAX consumes raw arrays
|
|
752
|
+
rebuilt every solve, so there's no analogue to CVXPy's parameter
|
|
753
|
+
cache.
|
|
754
|
+
"""
|
|
755
|
+
if self.layout is None:
|
|
756
|
+
return {"n_variables": 0, "n_parameters": 0, "n_constraints": 0}
|
|
757
|
+
return {
|
|
758
|
+
"n_variables": self.layout.n_z,
|
|
759
|
+
"n_parameters": 0,
|
|
760
|
+
# We don't precompute row counts; approximate by accumulating the
|
|
761
|
+
# always-on rows. The exact number depends on per-iteration
|
|
762
|
+
# constraint linearizations and Fix indices and isn't needed for
|
|
763
|
+
# diagnostics.
|
|
764
|
+
"n_constraints": -1,
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
def citation(self) -> List[str]:
|
|
768
|
+
"""BibTeX entry for QPAX (Tracy & Howell, 2024)."""
|
|
769
|
+
return [
|
|
770
|
+
r"""@article{tracy2024qpax,
|
|
771
|
+
title={QPAX: differentiable QP solver in JAX},
|
|
772
|
+
author={Tracy, Kevin and Howell, Taylor},
|
|
773
|
+
journal={arXiv preprint arXiv:2406.11749},
|
|
774
|
+
year={2024}
|
|
775
|
+
}"""
|
|
776
|
+
]
|