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.
@@ -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
+ ]