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.
@@ -1,21 +1,31 @@
1
- """CVXPy-based convex subproblem solver for the penalized trust-region (PTR) SCP algorithm.
2
-
3
- This module provides the default solver backend using CVXPy's modeling language
4
- with support for multiple backend solvers (CLARABEL, etc.). Includes optional
5
- code generation via cvxpygen for improved performance.
1
+ """Abstract base class for Penalized Trust-Region (PTR) convex subproblem solvers.
2
+
3
+ The PTR formulation its variables, slack structure, cost terms, and
4
+ linearization contract is shared across backends. This module defines that
5
+ contract; concrete backends (CVXPy, QPAX) live in sibling modules and
6
+ implement the assembly/dispatch that each backend's modeling layer requires.
7
+
8
+ Backends:
9
+ :class:`openscvx.solvers.cvxpy_ptr_solver.CVXPyPTRSolver`
10
+ DCP graph assembled via CVXPy, dispatched to a conic solver
11
+ (QOCO, CLARABEL, ...).
12
+ :class:`openscvx.solvers.qpax_ptr_solver.QPAXPTRSolver`
13
+ Flat ``(Q, q, A, b, G, h)`` assembled as JAX arrays and solved with
14
+ ``qpax.solve_qp``. Enables an end-to-end JAX-differentiable SCP loop
15
+ in follow-up work.
6
16
  """
7
17
 
8
- import os
18
+ from abc import abstractmethod
9
19
  from dataclasses import dataclass
10
- from typing import TYPE_CHECKING, List, Optional, Union
20
+ from typing import TYPE_CHECKING, List, Tuple, Union
11
21
 
12
- import cvxpy as cp
13
22
  import numpy as np
14
23
 
15
- from openscvx.config import Config
16
-
17
24
  from .base import ConvexSolver
18
25
 
26
+ if TYPE_CHECKING:
27
+ from openscvx.lowered.unified import UnifiedControl, UnifiedState
28
+
19
29
 
20
30
  @dataclass
21
31
  class PTRSolveResult:
@@ -45,601 +55,25 @@ class PTRSolveResult:
45
55
  status: str
46
56
 
47
57
 
48
- if TYPE_CHECKING:
49
- from openscvx.lowered import LoweredProblem
50
- from openscvx.lowered.cvxpy_variables import CVXPyVariables
51
- from openscvx.lowered.jax_constraints import LoweredJaxConstraints
52
- from openscvx.lowered.unified import UnifiedControl, UnifiedState
53
-
54
- # Optional cvxpygen import
55
- try:
56
- from cvxpygen import cpg
57
-
58
- CVXPYGEN_AVAILABLE = True
59
- except ImportError:
60
- CVXPYGEN_AVAILABLE = False
61
- cpg = None
62
-
63
-
64
- ## TODO: (fabio) add support for impulsive controls
65
-
66
-
67
58
  class PTRSolver(ConvexSolver):
68
- """CVXPy-based convex subproblem solver for the PTR algorithm.
69
-
70
- This solver uses CVXPy's modeling language to construct and solve the convex
71
- subproblems generated at each SCP iteration. It supports multiple backend
72
- solvers (CLARABEL, ECOS, MOSEK, etc.) and optional code generation via
73
- cvxpygen for improved performance.
74
-
75
- The solver builds the problem structure once during ``initialize()``, using
76
- CVXPy Parameters for values that change each iteration. The ``solve()``
77
- method then solves and returns a structured ``PTRSolveResult``.
78
-
79
- The cost and constraint formulations are defined in the ``cost()`` and
80
- ``constraints()`` methods, which can be overridden in subclasses to
81
- customize the convex subproblem. For example::
82
-
83
- class MyPTRSolver(PTRSolver):
84
- def cost(self, settings, lowered):
85
- c = super().cost(settings, lowered)
86
- c += my_extra_term(self._ocp_vars)
87
- return c
88
-
89
- !!! note "Future Backend Support"
90
-
91
- When adding a new backend (QPAX, COCO, etc.), this class should be
92
- refactored:
59
+ """Abstract base class for Penalized Trust-Region convex subproblem solvers.
93
60
 
94
- 1. Rename ``PTRSolver`` to ``CVXPyPTRSolver``
95
- 2. Extract ``PTRSolver`` as an abstract base class defining the PTR
96
- interface (``update_dynamics_linearization``, ``update_constraint_linearizations``,
97
- ``update_penalties``, ``solve`` returning ``PTRSolveResult``)
98
- 3. Have ``CVXPyPTRSolver`` and the new backend (e.g., ``QPAXPTRSolver``)
99
- inherit from the abstract ``PTRSolver``
61
+ Defines the contract every PTR backend must satisfy: per-iteration entry
62
+ points for updating the linearization point, constraint gradients, penalty
63
+ weights, and boundary conditions, plus a ``solve()`` returning a
64
+ :class:`PTRSolveResult`. The set of variables (state, control, virtual
65
+ control ``nu``, per-constraint virtual buffer ``nu_vb``, cross-node slack
66
+ ``nu_vb_cross``) is fixed by the PTR formulation; how each backend
67
+ realizes those variables is an implementation detail.
100
68
 
101
- This keeps the algorithm backend-agnostic while allowing multiple
102
- solver implementations for the PTR formulation.
69
+ Subclasses must additionally implement :meth:`create_variables` and
70
+ :meth:`initialize` from :class:`ConvexSolver`.
103
71
 
104
- Example:
105
- Using PTRSolver with the SCP framework::
106
-
107
- solver = PTRSolver()
108
- solver.create_variables(N, x_unified, u_unified, jax_constraints)
109
- solver.initialize(lowered, settings)
110
-
111
- # Each iteration (parameter updates done by algorithm):
112
- result = solver.solve()
113
- x_sol = result.x # Unscaled state trajectory
114
-
115
- Args:
116
- cvx_solver: CVXPY solver backend name. Defaults to ``"QOCO"``.
117
- solver_args: Keyword arguments forwarded to the CVXPY solver
118
- (e.g. tolerances). Defaults to
119
- ``{"abstol": 1e-6, "reltol": 1e-9, "enforce_dpp": True}``.
120
- cvxpygen: Enable CVXPy code generation for faster solves.
121
- Defaults to ``False``.
122
-
123
- !!! warning
124
- Enabling cvxpygen currently disables sparse parameter
125
- declarations. cvxpygen does not yet support the N-D sparsity
126
- indices used by OpenSCvx's tiled parameters, so all parameters
127
- are created as dense when code generation is active. This may
128
- increase the generated solver's memory footprint and compile
129
- time but does not affect solution correctness.
130
- cvxpygen_override: Overwrite existing generated solver directory
131
- without prompting. Defaults to ``False``.
132
-
133
- Attributes:
134
- ocp_vars: The CVXPy variables and parameters (available after create_variables())
72
+ Implementations: :class:`openscvx.solvers.cvxpy_ptr_solver.CVXPyPTRSolver`
73
+ and :class:`openscvx.solvers.qpax_ptr_solver.QPAXPTRSolver`.
135
74
  """
136
75
 
137
- def __init__(
138
- self,
139
- cvx_solver: str = "QOCO",
140
- solver_args: Optional[dict] = None,
141
- cvxpygen: bool = False,
142
- cvxpygen_override: bool = False,
143
- ):
144
- """Initialize PTRSolver with solver configuration.
145
-
146
- Call create_variables() then initialize() to build the problem structure.
147
- """
148
- self.cvx_solver = cvx_solver
149
- self.solver_args = (
150
- solver_args
151
- if solver_args is not None
152
- else {"abstol": 1e-06, "reltol": 1e-09, "enforce_dpp": True}
153
- )
154
- self.cvxpygen = cvxpygen
155
- self.cvxpygen_override = cvxpygen_override
156
-
157
- self._ocp_vars: "CVXPyVariables" = None
158
- self._problem: cp.Problem = None
159
- self._solve_fn: callable = None
160
-
161
- @property
162
- def ocp_vars(self) -> "CVXPyVariables":
163
- """The CVXPy variables and parameters.
164
-
165
- Returns:
166
- The CVXPyVariables dataclass, or None if create_variables() not called.
167
- """
168
- return self._ocp_vars
169
-
170
- def create_variables(
171
- self,
172
- N: int,
173
- x_unified: "UnifiedState",
174
- u_unified: "UnifiedControl",
175
- jax_constraints: "LoweredJaxConstraints",
176
- dynamics_sparsity: Optional[tuple] = None,
177
- constraint_sparsity: Optional[list] = None,
178
- ) -> None:
179
- """Create CVXPy optimization variables.
180
-
181
- Creates all CVXPy Variable and Parameter objects needed for the optimal
182
- control problem. This includes state/control variables, dynamics parameters,
183
- constraint linearization parameters, and scaling matrices.
184
-
185
- Args:
186
- N: Number of discretization nodes
187
- x_unified: Unified state interface with dimensions and scaling bounds
188
- u_unified: Unified control interface with dimensions and scaling bounds
189
- jax_constraints: Lowered JAX constraints (for sizing linearization params)
190
- dynamics_sparsity: Optional tuple ``(A_d, B_d, C_d)`` of boolean
191
- ndarrays giving the discrete-time Jacobian sparsity patterns.
192
- ``A_d`` has shape ``(n_x, n_x)``; ``B_d`` and ``C_d`` have
193
- shape ``(n_x, n_u)``.
194
- constraint_sparsity: Optional list of ``(x_mask, u_mask)`` boolean
195
- 1-D arrays, one per nodal constraint.
196
- """
197
- from openscvx.config import get_affine_scaling_matrices
198
- from openscvx.symbolic.lower import _tile_sparsity, create_cvxpy_variables
199
-
200
- n_states = len(x_unified.max)
201
- n_controls = len(u_unified.max)
202
- slice_cont = u_unified.slice_continuous
203
- slice_imp = u_unified.slice_impulsive
204
- n_controls_cont = int(slice_cont.stop - slice_cont.start)
205
- n_controls_imp = int(slice_imp.stop - slice_imp.start)
206
- if n_controls_cont + n_controls_imp != n_controls:
207
- raise ValueError(
208
- "Unified control slices are inconsistent with control dimension. "
209
- f"continuous={n_controls_cont}, impulsive={n_controls_imp}, total={n_controls}."
210
- )
211
-
212
- # Compute scaling matrices from unified object bounds
213
- if x_unified.scaling_min is not None:
214
- lower_x = np.array(x_unified.scaling_min, dtype=float)
215
- else:
216
- lower_x = np.array(x_unified.min, dtype=float)
217
-
218
- if x_unified.scaling_max is not None:
219
- upper_x = np.array(x_unified.scaling_max, dtype=float)
220
- else:
221
- upper_x = np.array(x_unified.max, dtype=float)
222
-
223
- S_x, c_x = get_affine_scaling_matrices(n_states, lower_x, upper_x)
224
-
225
- if u_unified.scaling_min is not None:
226
- lower_u = np.array(u_unified.scaling_min, dtype=float)
227
- else:
228
- lower_u = np.array(u_unified.min, dtype=float)
229
-
230
- if u_unified.scaling_max is not None:
231
- upper_u = np.array(u_unified.scaling_max, dtype=float)
232
- else:
233
- upper_u = np.array(u_unified.max, dtype=float)
234
-
235
- S_u, c_u = get_affine_scaling_matrices(n_controls, lower_u, upper_u)
236
-
237
- # Convert boolean sparsity patterns to CVXPY index format
238
- A_d_sp = B_d_sp = C_d_sp = None
239
- if dynamics_sparsity is not None:
240
- A_d_pat, B_d_pat, C_d_pat = dynamics_sparsity
241
- A_d_sp = _tile_sparsity(A_d_pat, N - 1)
242
- B_d_sp = _tile_sparsity(B_d_pat, N - 1)
243
- C_d_sp = _tile_sparsity(C_d_pat, N - 1)
244
-
245
- # TODO: (griffin-norris) Remove once cvxpygen supports N-D sparsity
246
- # indices. cvxpygen's handle_sparsity() assumes 2-D (rows, cols) but
247
- # our tiled parameters produce 3-D indices (slices, rows, cols).
248
- # Dropping sparsity here is safe — it only affects codegen performance.
249
- if self.cvxpygen:
250
- A_d_sp = B_d_sp = C_d_sp = None
251
- constraint_sparsity = None
252
-
253
- # Create all CVXPy variables for the OCP
254
- self._ocp_vars = create_cvxpy_variables(
255
- N=N,
256
- n_states=n_states,
257
- n_controls=n_controls,
258
- S_x=S_x,
259
- c_x=c_x,
260
- S_u=S_u,
261
- c_u=c_u,
262
- n_nodal_constraints=len(jax_constraints.nodal),
263
- n_cross_node_constraints=len(jax_constraints.cross_node),
264
- A_d_sparsity=A_d_sp,
265
- B_d_sparsity=B_d_sp,
266
- C_d_sparsity=C_d_sp,
267
- constraint_sparsity=constraint_sparsity,
268
- )
269
-
270
- def initialize(
271
- self,
272
- lowered: "LoweredProblem",
273
- settings: "Config",
274
- ) -> None:
275
- """Build the CVXPy optimal control problem.
276
-
277
- Constructs the complete optimization problem by calling ``cost()`` and
278
- ``constraints()`` to build the objective and constraint formulations,
279
- then assembles them into a CVXPy Problem.
280
-
281
- If cvxpygen is enabled, generates compiled solver code for improved
282
- performance.
283
-
284
- Note:
285
- ``create_variables()`` must be called before this method.
286
-
287
- Args:
288
- lowered: Lowered problem containing:
289
- - ``cvxpy_constraints``: Lowered convex constraints
290
- - ``jax_constraints``: JAX constraint functions (for structure)
291
- settings: Problem configuration (node count, scaling, etc.)
292
-
293
- Raises:
294
- RuntimeError: If create_variables() has not been called.
295
- """
296
- if self._ocp_vars is None:
297
- raise RuntimeError(
298
- "PTRSolver.initialize() called before create_variables(). "
299
- "Call create_variables() first to create optimization variables."
300
- )
301
-
302
- objective = self.cost(settings, lowered)
303
- constr = self.constraints(settings, lowered)
304
- prob = cp.Problem(cp.Minimize(objective), constr)
305
-
306
- if self.cvxpygen:
307
- if not CVXPYGEN_AVAILABLE:
308
- raise ImportError(
309
- "cvxpygen is required for code generation but not installed. "
310
- "Install it with: pip install openscvx[cvxpygen] or pip install cvxpygen"
311
- )
312
- # Check to see if solver directory exists
313
- if not os.path.exists("solver"):
314
- cpg.generate_code(prob, solver=self.cvx_solver, code_dir="solver", wrapper=True)
315
- else:
316
- # Prompt the use to indicate if they wish to overwrite the solver
317
- # directory or use the existing compiled solver
318
- if self.cvxpygen_override:
319
- cpg.generate_code(
320
- prob,
321
- solver=self.cvx_solver,
322
- code_dir="solver",
323
- wrapper=True,
324
- )
325
- else:
326
- overwrite = input("Solver directory already exists. Overwrite? (y/n): ")
327
- if overwrite.lower() == "y":
328
- cpg.generate_code(
329
- prob,
330
- solver=self.cvx_solver,
331
- code_dir="solver",
332
- wrapper=True,
333
- )
334
-
335
- self._problem = prob
336
- self._setup_solve_function()
337
-
338
- def cost(
339
- self,
340
- settings: "Config",
341
- lowered: "LoweredProblem",
342
- ) -> cp.Expression:
343
- """Build the cost expression for the convex subproblem.
344
-
345
- Constructs the PTR objective function including:
346
-
347
- - Boundary condition costs (Minimize/Maximize state components)
348
- - Trust region penalty (deviation from linearization point)
349
- - Virtual control penalty (dynamics defect relaxation)
350
- - Virtual buffer penalty (nonconvex constraint violation relaxation)
351
-
352
- Override this method in subclasses to customize the cost formulation.
353
- Use ``super().cost(settings, lowered)`` to include the standard PTR
354
- cost terms and add to them.
355
-
356
- Args:
357
- settings: Configuration object with solver settings
358
- lowered: Lowered problem containing constraint structure
359
-
360
- Returns:
361
- CVXPy expression representing the total cost to minimize.
362
- """
363
- ocp_vars = self._ocp_vars
364
- jax_constraints = lowered.jax_constraints
365
-
366
- lam_prox = ocp_vars.lam_prox
367
- lam_cost = ocp_vars.lam_cost
368
- lam_vc = ocp_vars.lam_vc
369
- lam_vb_nodal = ocp_vars.lam_vb_nodal
370
- lam_vb_cross = ocp_vars.lam_vb_cross
371
- _ = ocp_vars.x_nonscaled
372
- dx = ocp_vars.dx
373
- du = ocp_vars.du
374
- nu = ocp_vars.nu
375
- nu_vb = ocp_vars.nu_vb
376
- nu_vb_cross = ocp_vars.nu_vb_cross
377
-
378
- cost = cp.sum(lam_cost) * 0
379
- cost += cp.sum(lam_vb_nodal) * 0
380
- cost += cp.sum(lam_vb_cross) * 0
381
-
382
- # Boundary condition cost terms (use scaled x for numerical conditioning)
383
- x = ocp_vars.x
384
- for i in range(settings.sim.true_state_slice.start, settings.sim.true_state_slice.stop):
385
- if settings.sim.x.initial_type[i] == "Minimize":
386
- cost += lam_cost[i] * x[0][i]
387
- if settings.sim.x.final_type[i] == "Minimize":
388
- cost += lam_cost[i] * x[-1][i]
389
- if settings.sim.x.initial_type[i] == "Maximize":
390
- cost -= lam_cost[i] * x[0][i]
391
- if settings.sim.x.final_type[i] == "Maximize":
392
- cost -= lam_cost[i] * x[-1][i]
393
-
394
- # Trust Region Cost (per-variable weighting)
395
- cost += sum(
396
- cp.sum(cp.multiply(lam_prox[i], cp.square(cp.hstack((dx[i], du[i])))))
397
- for i in range(settings.sim.n)
398
- )
399
-
400
- # Virtual Control Slack
401
- cost += sum(cp.sum(lam_vc[i - 1] * cp.abs(nu[i - 1])) for i in range(1, settings.sim.n))
402
-
403
- # Virtual buffer penalty for nodal constraints (per-node weighting)
404
- idx_ncvx = 0
405
- if jax_constraints.nodal:
406
- for constraint in jax_constraints.nodal:
407
- cost += lam_vb_nodal[:, idx_ncvx] @ cp.pos(nu_vb[idx_ncvx])
408
- idx_ncvx += 1
409
-
410
- # Virtual slack penalty for cross-node constraints
411
- idx_cross = 0
412
- if jax_constraints.cross_node:
413
- for constraint in jax_constraints.cross_node:
414
- cost += lam_vb_cross[idx_cross] * cp.pos(nu_vb_cross[idx_cross])
415
- idx_cross += 1
416
-
417
- return cost
418
-
419
- def constraints(
420
- self,
421
- settings: "Config",
422
- lowered: "LoweredProblem",
423
- ) -> list:
424
- """Build the constraint list for the convex subproblem.
425
-
426
- Constructs all PTR constraints including:
427
-
428
- - Linearized nodal constraints (from JAX-lowered nonconvex constraints)
429
- - Linearized cross-node constraints
430
- - Convex constraints (already lowered to CVXPy)
431
- - Boundary conditions (fixed initial/terminal states)
432
- - Uniform time grid constraints
433
- - State and control deviation definitions
434
- - Linearized dynamics
435
- - State and control box constraints
436
- - CTCS constraints
437
-
438
- Override this method in subclasses to customize the constraint
439
- formulation. Use ``super().constraints(settings, lowered)`` to include
440
- the standard PTR constraints and extend them.
441
-
442
- Args:
443
- settings: Configuration object with solver settings
444
- lowered: Lowered problem containing lowered constraints
445
-
446
- Returns:
447
- List of CVXPy constraints.
448
- """
449
- ocp_vars = self._ocp_vars
450
- jax_constraints = lowered.jax_constraints
451
- cvxpy_constraints = lowered.cvxpy_constraints
452
-
453
- x = ocp_vars.x
454
- dx = ocp_vars.dx
455
- x_bar = ocp_vars.x_bar
456
- x_init = ocp_vars.x_init
457
- x_term = ocp_vars.x_term
458
- u = ocp_vars.u
459
- du = ocp_vars.du
460
- u_bar = ocp_vars.u_bar
461
- A_d = ocp_vars.A_d
462
- B_d = ocp_vars.B_d
463
- C_d = ocp_vars.C_d
464
- x_prop = ocp_vars.x_prop
465
- x_prop_plus = ocp_vars.x_prop_plus
466
- E_d = ocp_vars.E_d
467
- nu = ocp_vars.nu
468
- g = ocp_vars.g
469
- grad_g_x = ocp_vars.grad_g_x
470
- grad_g_u = ocp_vars.grad_g_u
471
- nu_vb = ocp_vars.nu_vb
472
- g_cross = ocp_vars.g_cross
473
- grad_g_X_cross = ocp_vars.grad_g_X_cross
474
- grad_g_U_cross = ocp_vars.grad_g_U_cross
475
- nu_vb_cross = ocp_vars.nu_vb_cross
476
- inv_S_x = ocp_vars.inv_S_x
477
- c_x = ocp_vars.c_x
478
- inv_S_u = ocp_vars.inv_S_u
479
- c_u = ocp_vars.c_u
480
- x_nonscaled = ocp_vars.x_nonscaled
481
- u_nonscaled = ocp_vars.u_nonscaled
482
- dx_nonscaled = ocp_vars.dx_nonscaled
483
- du_nonscaled = ocp_vars.du_nonscaled
484
- slice_cont = settings.sim.u.slice_continuous
485
- slice_imp = settings.sim.u.slice_impulsive
486
- has_continuous = bool(slice_cont.stop > slice_cont.start)
487
- has_impulsive = bool(slice_imp.stop > slice_imp.start)
488
-
489
- constr = []
490
-
491
- # Linearized nodal constraints (from JAX-lowered non-convex)
492
- idx_ncvx = 0
493
- if jax_constraints.nodal:
494
- for constraint in jax_constraints.nodal:
495
- # nodes should already be validated and normalized in preprocessing
496
- nodes = constraint.nodes
497
- for node in nodes:
498
- residual = (
499
- g[idx_ncvx][node]
500
- + grad_g_x[idx_ncvx][node] @ dx[node]
501
- + grad_g_u[idx_ncvx][node] @ du[node]
502
- )
503
- constr += [residual == nu_vb[idx_ncvx][node]]
504
- idx_ncvx += 1
505
-
506
- # Linearized cross-node constraints (from JAX-lowered non-convex)
507
- idx_cross = 0
508
- if jax_constraints.cross_node:
509
- for constraint in jax_constraints.cross_node:
510
- # Linearization: g(X_bar, U_bar) + ∇g_X @ dX + ∇g_U @ dU == nu_vb
511
- # Sum over all trajectory nodes to couple multiple nodes
512
- residual = g_cross[idx_cross]
513
- for k in range(settings.sim.n):
514
- # Contribution from state at node k
515
- residual += grad_g_X_cross[idx_cross][k, :] @ dx[k]
516
- # Contribution from control at node k
517
- residual += grad_g_U_cross[idx_cross][k, :] @ du[k]
518
- # Add constraint: residual == slack variable
519
- constr += [residual == nu_vb_cross[idx_cross]]
520
- idx_cross += 1
521
-
522
- # Convex constraints (already lowered to CVXPy)
523
- if cvxpy_constraints.constraints:
524
- constr += cvxpy_constraints.constraints
525
-
526
- # Boundary conditions (Fix)
527
- for i in range(settings.sim.true_state_slice.start, settings.sim.true_state_slice.stop):
528
- if settings.sim.x.initial_type[i] == "Fix":
529
- if has_impulsive:
530
- constr += [
531
- x_nonscaled[0][i]
532
- == x_prop_plus[0][i] + E_d[0][i, slice_imp] @ du_nonscaled[0][slice_imp]
533
- ]
534
- else:
535
- constr += [x_nonscaled[0][i] == x_init[i]] # Initial Boundary Conditions
536
- if settings.sim.x.final_type[i] == "Fix":
537
- constr += [x_nonscaled[-1][i] == x_term[i]] # Final Boundary Conditions
538
-
539
- if settings.sim._uniform_time_grid:
540
- S_u_inv_td = inv_S_u[settings.sim.time_dilation_slice, settings.sim.time_dilation_slice]
541
- c_u_td = c_u[settings.sim.time_dilation_slice]
542
- constr += [
543
- S_u_inv_td @ (u_nonscaled[i][settings.sim.time_dilation_slice] - c_u_td)
544
- == S_u_inv_td @ (u_nonscaled[i - 1][settings.sim.time_dilation_slice] - c_u_td)
545
- for i in range(1, settings.sim.n)
546
- ]
547
-
548
- constr += [
549
- (x[i] - inv_S_x @ (x_bar[i] - c_x) - dx[i]) == 0 for i in range(settings.sim.n)
550
- ] # State Error
551
- constr += [
552
- (u[i] - inv_S_u @ (u_bar[i] - c_u) - du[i]) == 0 for i in range(settings.sim.n)
553
- ] # Control Error
554
-
555
- constr += [
556
- inv_S_x @ (x_nonscaled[i] - c_x)
557
- == inv_S_x
558
- @ (
559
- A_d[i - 1] @ dx_nonscaled[i - 1]
560
- + (
561
- B_d[i - 1][:, slice_cont] @ du_nonscaled[i - 1][slice_cont]
562
- if has_continuous
563
- else 0
564
- )
565
- + (C_d[i - 1][:, slice_cont] @ du_nonscaled[i][slice_cont] if has_continuous else 0)
566
- + (E_d[i][:, slice_imp] @ du_nonscaled[i][slice_imp] if has_impulsive else 0)
567
- + (x_prop_plus[i] if has_impulsive else x_prop[i - 1])
568
- - c_x
569
- )
570
- + nu[i - 1]
571
- for i in range(1, settings.sim.n)
572
- ] # Dynamics Constraint
573
-
574
- constr += [
575
- inv_S_u @ (u_nonscaled[i] - c_u) <= inv_S_u @ (settings.sim.u.max - c_u)
576
- for i in range(settings.sim.n)
577
- ]
578
- constr += [
579
- inv_S_u @ (u_nonscaled[i] - c_u) >= inv_S_u @ (settings.sim.u.min - c_u)
580
- for i in range(settings.sim.n)
581
- ] # Control Constraints
582
-
583
- # TODO: (norrisg) formalize this
584
- constr += [
585
- inv_S_x @ (x_nonscaled[i][:] - c_x) <= inv_S_x @ (settings.sim.x.max - c_x)
586
- for i in range(settings.sim.n)
587
- ]
588
- constr += [
589
- inv_S_x @ (x_nonscaled[i][:] - c_x) >= inv_S_x @ (settings.sim.x.min - c_x)
590
- for i in range(settings.sim.n)
591
- ] # State Constraints (Also implemented in CTCS but included for numerical stability)
592
-
593
- for idx, nodes in zip(
594
- np.arange(settings.sim.ctcs_slice.start, settings.sim.ctcs_slice.stop),
595
- settings.sim.ctcs_node_intervals,
596
- ):
597
- start_idx = 1 if nodes[0] == 0 else nodes[0]
598
- constr += [
599
- cp.abs(x_nonscaled[i][idx] - x_nonscaled[i - 1][idx]) <= settings.sim.x.max[idx]
600
- for i in range(start_idx, nodes[1])
601
- ]
602
- constr += [x_nonscaled[0][idx] == 0]
603
-
604
- return constr
605
-
606
- def _setup_solve_function(self) -> None:
607
- """Configure the solve function based on solver settings.
608
-
609
- Sets up either cvxpygen-based solving or standard CVXPy solving
610
- based on the configuration.
611
- """
612
- if self.cvxpygen:
613
- try:
614
- import pickle
615
-
616
- from solver.cpg_solver import cpg_solve
617
-
618
- with open("solver/problem.pickle", "rb") as f:
619
- pickle.load(f)
620
- self._problem.register_solve("CPG", cpg_solve)
621
- solver_args = self.solver_args
622
- self._solve_fn = lambda: self._problem.solve(method="CPG", **solver_args)
623
- except ImportError:
624
- raise ImportError(
625
- "cvxpygen solver not found. Make sure cvxpygen is installed and code "
626
- "generation has been run. Install with: pip install openscvx[cvxpygen]"
627
- )
628
- else:
629
- solver = self.cvx_solver
630
- solver_args = dict(self.solver_args)
631
-
632
- def _solve_with_dpp_fallback():
633
- try:
634
- return self._problem.solve(solver=solver, **solver_args)
635
- except cp.error.DPPError:
636
- fallback_args = dict(solver_args)
637
- fallback_args.pop("enforce_dpp", None)
638
- fallback_args["ignore_dpp"] = True
639
- return self._problem.solve(solver=solver, **fallback_args)
640
-
641
- self._solve_fn = _solve_with_dpp_fallback
642
-
76
+ @abstractmethod
643
77
  def update_dynamics_linearization(
644
78
  self,
645
79
  x_bar: np.ndarray,
@@ -652,61 +86,28 @@ class PTRSolver(ConvexSolver):
652
86
  D_d: np.ndarray | None = None,
653
87
  E_d: np.ndarray | None = None,
654
88
  ) -> None:
655
- """Update dynamics linearization point and matrices.
656
-
657
- Sets the current linearization point (previous iterate) and the
658
- discretized dynamics matrices for the convex subproblem.
89
+ """Update dynamics linearization point and discrete-time matrices.
659
90
 
660
91
  Args:
661
- x_bar: Previous state trajectory, shape (N, n_states)
662
- u_bar: Previous control trajectory, shape (N, n_controls)
663
- A_d: Discretized state Jacobian, shape (N-1, n_states, n_states)
664
- B_d: Discretized control Jacobian (current node), shape (N-1, n_states, n_controls)
665
- C_d: Discretized control Jacobian (next node), shape (N-1, n_states, n_controls)
666
- x_prop: Propagated state from continuous dynamics, shape (N-1, n_states)
667
- x_prop_plus: Optional impulsive/discrete propagated state, shape (N, n_states)
668
- D_d: Optional impulsive/discrete Jacobian wrt state, shape (N, n_states, n_states)
669
- E_d: Optional impulsive/discrete Jacobian wrt control, shape (N, n_states, n_controls)
92
+ x_bar: Previous state trajectory, shape (N, n_states).
93
+ u_bar: Previous control trajectory, shape (N, n_controls).
94
+ A_d: Discretized state Jacobian, shape (N-1, n_states, n_states).
95
+ B_d: Discretized control Jacobian (current node),
96
+ shape (N-1, n_states, n_controls).
97
+ C_d: Discretized control Jacobian (next node),
98
+ shape (N-1, n_states, n_controls).
99
+ x_prop: Propagated state from continuous dynamics,
100
+ shape (N-1, n_states).
101
+ x_prop_plus: Optional impulsive/discrete propagated state,
102
+ shape (N, n_states).
103
+ D_d: Optional impulsive/discrete Jacobian wrt state,
104
+ shape (N, n_states, n_states).
105
+ E_d: Optional impulsive/discrete Jacobian wrt control,
106
+ shape (N, n_states, n_controls).
670
107
  """
671
- self._set_param("x_bar", x_bar)
672
- self._set_param("u_bar", u_bar)
673
-
674
- A_eff = np.asarray(A_d)
675
- B_eff = np.asarray(B_d)
676
- C_eff = np.asarray(C_d)
677
-
678
- if D_d is not None:
679
- D_arr = np.asarray(D_d)
680
- # Temporary DPP-safe workaround: absorb D_d into A/B/C numerically
681
- # so the CVXPY graph has only Parameter@Variable products.
682
- if D_arr.ndim == 3 and D_arr.shape[0] == A_eff.shape[0] + 1:
683
- D_steps = D_arr[1:]
684
- elif D_arr.ndim == 3 and D_arr.shape[0] == A_eff.shape[0]:
685
- D_steps = D_arr
686
- else:
687
- raise ValueError(
688
- "Unexpected D_d shape for dynamics update: "
689
- f"{D_arr.shape}, expected "
690
- f"{(A_eff.shape[0] + 1, A_eff.shape[1], A_eff.shape[2])} "
691
- f"or {(A_eff.shape[0], A_eff.shape[1], A_eff.shape[2])}."
692
- )
693
-
694
- A_eff = np.einsum("kij,kjl->kil", D_steps, A_eff)
695
- B_eff = np.einsum("kij,kjl->kil", D_steps, B_eff)
696
- C_eff = np.einsum("kij,kjl->kil", D_steps, C_eff)
697
-
698
- self._set_param("A_d", A_eff)
699
- self._set_param("B_d", B_eff)
700
- self._set_param("C_d", C_eff)
701
- if "x_prop" in self._problem.param_dict:
702
- self._set_param("x_prop", x_prop)
703
- elif self._ocp_vars.x_prop is not None:
704
- self._ocp_vars.x_prop.value = np.asarray(x_prop)
705
- if x_prop_plus is not None and self._ocp_vars.x_prop_plus is not None:
706
- self._ocp_vars.x_prop_plus.value = np.asarray(x_prop_plus)
707
- if E_d is not None and self._ocp_vars.E_d is not None:
708
- self._ocp_vars.E_d.value = np.asarray(E_d)
108
+ raise NotImplementedError
709
109
 
110
+ @abstractmethod
710
111
  def update_constraint_linearizations(
711
112
  self,
712
113
  nodal: List[dict] = None,
@@ -714,31 +115,19 @@ class PTRSolver(ConvexSolver):
714
115
  ) -> None:
715
116
  """Update linearized constraint values and gradients.
716
117
 
717
- Sets constraint function values and gradients at the current
718
- linearization point for both nodal and cross-node constraints.
719
-
720
118
  Args:
721
- nodal: List of dicts for nodal constraints, each containing:
722
- - ``g``: Constraint value at linearization point
723
- - ``grad_g_x``: Gradient w.r.t. state
724
- - ``grad_g_u``: Gradient w.r.t. control
725
- cross_node: List of dicts for cross-node constraints, each containing:
726
- - ``g``: Constraint value at linearization point
727
- - ``grad_g_X``: Gradient w.r.t. full state trajectory
728
- - ``grad_g_U``: Gradient w.r.t. full control trajectory
119
+ nodal: List of dicts, one per nodal constraint, each containing:
120
+ ``g`` (value at linearization point),
121
+ ``grad_g_x`` (gradient w.r.t. state),
122
+ ``grad_g_u`` (gradient w.r.t. control).
123
+ cross_node: List of dicts, one per cross-node constraint, each
124
+ containing: ``g``, ``grad_g_X`` (gradient w.r.t. full state
125
+ trajectory), ``grad_g_U`` (gradient w.r.t. full control
126
+ trajectory).
729
127
  """
730
- if nodal:
731
- for g_id, constraint_data in enumerate(nodal):
732
- self._set_param(f"g_{g_id}", constraint_data["g"])
733
- self._set_param(f"grad_g_x_{g_id}", constraint_data["grad_g_x"])
734
- self._set_param(f"grad_g_u_{g_id}", constraint_data["grad_g_u"])
735
-
736
- if cross_node:
737
- for g_id, constraint_data in enumerate(cross_node):
738
- self._set_param(f"g_cross_{g_id}", constraint_data["g"])
739
- self._set_param(f"grad_g_X_cross_{g_id}", constraint_data["grad_g_X"])
740
- self._set_param(f"grad_g_U_cross_{g_id}", constraint_data["grad_g_U"])
128
+ raise NotImplementedError
741
129
 
130
+ @abstractmethod
742
131
  def update_penalties(
743
132
  self,
744
133
  lam_prox: np.ndarray,
@@ -749,197 +138,59 @@ class PTRSolver(ConvexSolver):
749
138
  ) -> None:
750
139
  """Update SCP penalty weights.
751
140
 
752
- Sets the penalty weights that balance competing objectives in the
753
- PTR convex subproblem.
754
-
755
141
  Args:
756
- lam_prox: Trust region weights, shape ``(N, n_states + n_controls)``.
757
- lam_cost: Cost function weight. Scalar or array of shape
758
- ``(n_states,)`` for per-state weighting.
759
- lam_vc: Virtual control penalty weights, shape (N-1, n_states)
142
+ lam_prox: Trust region weights, shape (N, n_states + n_controls).
143
+ lam_cost: Cost function weight. Scalar or shape (n_states,).
144
+ lam_vc: Virtual control penalty weights, shape (N-1, n_states).
760
145
  lam_vb_nodal: Virtual buffer penalty weights for nodal constraints,
761
- shape ``(N, n_nodal_constraints)``.
146
+ shape (N, n_nodal_constraints).
762
147
  lam_vb_cross: Virtual buffer penalty weights for cross-node
763
- constraints, shape ``(n_cross_node_constraints,)``.
148
+ constraints, shape (n_cross_node_constraints,).
764
149
  """
765
- self._set_param("lam_prox", lam_prox)
766
- self._set_param("lam_cost", lam_cost)
767
- self._set_param("lam_vc", lam_vc)
768
- self._set_param("lam_vb_nodal", lam_vb_nodal)
769
- self._set_param("lam_vb_cross", lam_vb_cross)
150
+ raise NotImplementedError
770
151
 
152
+ @abstractmethod
771
153
  def update_boundary_conditions(
772
154
  self,
773
155
  x_init: np.ndarray = None,
774
156
  x_term: np.ndarray = None,
775
157
  ) -> None:
776
- """Update boundary condition parameters.
777
-
778
- Sets initial and/or terminal state constraints. Only sets parameters
779
- that exist in the problem (some problems may not have both).
158
+ """Update initial and/or terminal state parameters.
780
159
 
781
160
  Args:
782
161
  x_init: Initial state vector, shape (n_states,). Optional.
783
162
  x_term: Terminal state vector, shape (n_states,). Optional.
784
163
  """
785
- if x_init is not None and "x_init" in self._problem.param_dict:
786
- self._set_param("x_init", x_init)
787
- if x_term is not None and "x_term" in self._problem.param_dict:
788
- self._set_param("x_term", x_term)
789
-
790
- def get_stats(self) -> dict:
791
- """Get solver statistics for diagnostics and printing.
792
-
793
- Returns:
794
- Dict containing:
795
- - ``n_variables``: Total number of optimization variables
796
- - ``n_parameters``: Total number of parameters
797
- - ``n_constraints``: Total number of constraints
798
- """
799
- if self._problem is None:
800
- return {"n_variables": 0, "n_parameters": 0, "n_constraints": 0}
801
-
802
- return {
803
- "n_variables": sum(var.size for var in self._problem.variables()),
804
- "n_parameters": sum(param.size for param in self._problem.parameters()),
805
- "n_constraints": sum(constraint.size for constraint in self._problem.constraints),
806
- }
807
-
808
- def _set_param(self, name: str, value: np.ndarray) -> None:
809
- """Set a CVXPy parameter with helpful error messages on failure.
810
-
811
- Args:
812
- name: The parameter name in problem.param_dict
813
- value: The value to assign
814
-
815
- Raises:
816
- ValueError: If the value is not real, with diagnostic information.
817
- """
818
- try:
819
- param = self._problem.param_dict[name]
820
- value_arr = np.asarray(value)
821
-
822
- # Ensure the value shape matches the parameter shape exactly
823
- # This is critical for Python 3.11+ where NumPy/CVXPy are stricter about shapes
824
- if hasattr(param, "shape") and param.shape is not None:
825
- expected_shape = param.shape
826
- if value_arr.shape != expected_shape:
827
- # Try to reshape if sizes match
828
- if value_arr.size == np.prod(expected_shape):
829
- value_arr = value_arr.reshape(expected_shape)
830
- else:
831
- # If sizes don't match, try squeezing extra dimensions first
832
- value_arr = np.squeeze(value_arr)
833
- if value_arr.shape != expected_shape and value_arr.size == np.prod(
834
- expected_shape
835
- ):
836
- value_arr = value_arr.reshape(expected_shape)
837
- elif value_arr.shape != expected_shape:
838
- raise ValueError(
839
- f"Parameter '{name}' shape mismatch: expected {expected_shape}, "
840
- f"got {value.shape} (after squeezing: {value_arr.shape})"
841
- )
842
-
843
- param.value = value_arr
844
- except ValueError as e:
845
- if "must be real" in str(e):
846
- arr = np.asarray(value)
847
- nan_mask = ~np.isfinite(arr)
848
- nan_indices = np.argwhere(nan_mask)
849
-
850
- index_value_strs = [
851
- f" {tuple(int(i) for i in idx)} -> {arr[tuple(idx)]}"
852
- for idx in nan_indices[:20]
853
- ]
854
- if len(nan_indices) > 20:
855
- index_value_strs.append(f" ... and {len(nan_indices) - 20} more")
856
-
857
- arr_str = np.array2string(arr, threshold=200, edgeitems=3, max_line_width=120)
858
- msg = (
859
- f"Parameter '{name}' with shape {arr.shape} contains "
860
- f"{len(nan_indices)} non-real value(s):\n"
861
- + "\n".join(index_value_strs)
862
- + f"\n\n{name} = {arr_str}"
863
- )
864
- raise ValueError(msg) from e
865
- raise
164
+ raise NotImplementedError
866
165
 
166
+ @abstractmethod
867
167
  def solve(self) -> PTRSolveResult:
868
- """Solve the convex subproblem and return structured results.
869
-
870
- Call ``update_dynamics_linearization()``, ``update_constraint_linearizations()``,
871
- and ``update_penalties()`` before calling this method.
168
+ """Solve the convex subproblem and return a :class:`PTRSolveResult`.
872
169
 
873
- Returns:
874
- PTRSolveResult containing unscaled trajectories, slack variables,
875
- cost, and solver status.
876
-
877
- Raises:
878
- RuntimeError: If initialize() has not been called.
170
+ Call the four ``update_*`` methods first to set the linearization
171
+ point, constraint gradients, penalties, and boundary conditions.
879
172
  """
880
- if self._problem is None:
881
- raise RuntimeError(
882
- "PTRSolver.solve() called before initialize(). "
883
- "Call initialize() first to build the problem structure."
884
- )
885
-
886
- self._solve_fn()
173
+ raise NotImplementedError
887
174
 
888
- # Get scaling matrices
889
- S_x = self._ocp_vars.S_x
890
- c_x = self._ocp_vars.c_x
891
- S_u = self._ocp_vars.S_u
892
- c_u = self._ocp_vars.c_u
175
+ @staticmethod
176
+ def _scaling(unified: Union["UnifiedState", "UnifiedControl"]) -> Tuple[np.ndarray, np.ndarray]:
177
+ """Compute the affine scaling matrices ``(S, c)`` for a unified
178
+ state or control interface.
893
179
 
894
- # Unscale state and control trajectories
895
- x_scaled = self._problem.var_dict["x"].value # (N, n_states)
896
- u_scaled = self._problem.var_dict["u"].value # (N, n_controls)
897
- x = (S_x @ x_scaled.T + np.expand_dims(c_x, axis=1)).T
898
- u = (S_u @ u_scaled.T + np.expand_dims(c_u, axis=1)).T
899
-
900
- # Get virtual control slack
901
- nu = self._problem.var_dict["nu"].value
902
-
903
- # Get nodal constraint violation slacks
904
- nu_vb = [var.value for var in self._ocp_vars.nu_vb]
905
-
906
- # Get cross-node constraint violation slacks
907
- nu_vb_cross = [var.value for var in self._ocp_vars.nu_vb_cross]
180
+ The PTR formulation works on scaled variables ``z = S⁻¹ (x - c)``;
181
+ ``S`` and ``c`` are chosen so the scaled variables sit roughly on
182
+ ``[-1, 1]``. Bounds come from ``scaling_min``/``scaling_max`` when
183
+ provided, otherwise from ``min``/``max``. Shared by every backend.
184
+ """
185
+ from openscvx.config import get_affine_scaling_matrices
908
186
 
909
- return PTRSolveResult(
910
- x=x,
911
- u=u,
912
- nu=nu,
913
- nu_vb=nu_vb,
914
- nu_vb_cross=nu_vb_cross,
915
- cost=self._problem.value,
916
- status=self._problem.status,
187
+ n = len(unified.max)
188
+ lower = np.array(
189
+ unified.scaling_min if unified.scaling_min is not None else unified.min,
190
+ dtype=float,
917
191
  )
918
-
919
- def citation(self) -> List[str]:
920
- """Return BibTeX citations for CVXPy.
921
-
922
- Returns:
923
- List containing BibTeX entries for CVXPy and DCCP papers.
924
- """
925
- return [
926
- r"""@article{diamond2016cvxpy,
927
- title={CVXPY: A Python-embedded modeling language for convex optimization},
928
- author={Diamond, Steven and Boyd, Stephen},
929
- journal={Journal of Machine Learning Research},
930
- volume={17},
931
- number={83},
932
- pages={1--5},
933
- year={2016}
934
- }""",
935
- r"""@article{agrawal2018rewriting,
936
- title={A rewriting system for convex optimization problems},
937
- author={Agrawal, Akshay and Verschueren, Robin and Diamond, Steven and Boyd, Stephen},
938
- journal={Journal of Control and Decision},
939
- volume={5},
940
- number={1},
941
- pages={42--60},
942
- year={2018},
943
- publisher={Taylor \& Francis}
944
- }""",
945
- ]
192
+ upper = np.array(
193
+ unified.scaling_max if unified.scaling_max is not None else unified.max,
194
+ dtype=float,
195
+ )
196
+ return get_affine_scaling_matrices(n, lower, upper)