Skip to content

base

Base class for successive convexification algorithms.

This module defines the abstract interface that all SCP algorithm implementations must follow, along with the AlgorithmState dataclass that holds mutable state during SCP iterations.

Algorithm

Bases: ABC

Abstract base class for successive convexification algorithms.

This class defines the interface for SCP algorithms used in trajectory optimization. Implementations should remain minimal and functional, delegating state management to the AlgorithmState dataclass.

The two core methods mirror the SCP workflow:

  • initialize: Store compiled infrastructure and warm-start solvers
  • step: Execute one convex subproblem iteration

Immutable components (ocp, discretization_solver, jax_constraints, etc.) are stored during initialize(). Mutable configuration (params, settings) is passed per-step to support runtime parameter updates and tolerance tuning.

Statefullness

Avoid storing mutable iteration state (costs, weights, trajectories) on self. All iteration state should live in :class:AlgorithmState or a subclass thereof, passed explicitly to step(). This keeps algorithm classes stateless w.r.t. iteration, making data flow explicit and staying close to functional programming principles where possible.

Example

Implementing a custom algorithm::

class MyAlgorithm(Algorithm):
    def initialize(self, solver, discretization_solver,
                   jax_constraints, emitter,
                   params, settings):
        # Store compiled infrastructure
        self._solver = solver
        self._discretization_solver = discretization_solver
        self._jax_constraints = jax_constraints
        self._emitter = emitter
        # Warm-start with initial params/settings...

    def step(self, state, params, settings):
        # Run one iteration using self._* and per-step params/settings
        return converged

Attributes:

Name Type Description
weights Weights

SCP weights used by the algorithm and autotuner. Subclasses must set this in __init__.

k_max int

Maximum number of SCP iterations. Subclasses must set this in __init__.

Source code in openscvx/algorithms/base.py
class Algorithm(ABC):
    """Abstract base class for successive convexification algorithms.

    This class defines the interface for SCP algorithms used in trajectory
    optimization. Implementations should remain minimal and functional,
    delegating state management to the AlgorithmState dataclass.

    The two core methods mirror the SCP workflow:

    - initialize: Store compiled infrastructure and warm-start solvers
    - step: Execute one convex subproblem iteration

    Immutable components (ocp, discretization_solver, jax_constraints, etc.) are
    stored during initialize(). Mutable configuration (params, settings) is passed
    per-step to support runtime parameter updates and tolerance tuning.

    !!! tip "Statefullness"
        Avoid storing mutable iteration state (costs, weights, trajectories) on
        ``self``. All iteration state should live in :class:`AlgorithmState` or
        a subclass thereof, passed explicitly to ``step()``. This keeps algorithm
        classes stateless w.r.t. iteration, making data flow explicit and staying
        close to functional programming principles where possible.

    Example:
        Implementing a custom algorithm::

            class MyAlgorithm(Algorithm):
                def initialize(self, solver, discretization_solver,
                               jax_constraints, emitter,
                               params, settings):
                    # Store compiled infrastructure
                    self._solver = solver
                    self._discretization_solver = discretization_solver
                    self._jax_constraints = jax_constraints
                    self._emitter = emitter
                    # Warm-start with initial params/settings...

                def step(self, state, params, settings):
                    # Run one iteration using self._* and per-step params/settings
                    return converged

    Attributes:
        weights: SCP weights used by the algorithm and autotuner.
            Subclasses must set this in ``__init__``.
        k_max: Maximum number of SCP iterations.
            Subclasses must set this in ``__init__``.
    """

    #: SCP weights. Subclasses must set this in ``__init__``.
    weights: "Weights"

    #: Maximum number of SCP iterations. Subclasses must set this in ``__init__``.
    k_max: int

    #: Optional wall-clock time limit in seconds. ``None`` means no limit.
    #: Subclasses must set this in ``__init__``.
    t_max: Optional[float]

    @abstractmethod
    def initialize(
        self,
        solver: "ConvexSolver",
        discretization_solver: callable,
        jax_constraints: "LoweredJaxConstraints",
        emitter: callable,
        params: dict,
        settings: "Config",
        discretization_solver_impulsive: Optional[Callable] = None,
    ) -> None:
        """Initialize the algorithm and store compiled infrastructure.

        This method stores immutable components and performs any setup required
        before the SCP loop begins (e.g., warm-starting solvers). The params and
        settings are passed for warm-start but may change between steps.

        Args:
            solver: Convex subproblem solver (e.g., CVXPySolver)
            discretization_solver: Compiled discretization solver function
            jax_constraints: JIT-compiled JAX constraint functions
            emitter: Callback for emitting iteration progress data
            params: Problem parameters dictionary (for warm-start only)
            settings: Configuration object (for warm-start only)
            discretization_solver_impulsive: Optional solver for discrete/impulsive
                dynamics evaluated on ``(x_prop, u_discrete)``
        """
        raise NotImplementedError

    @abstractmethod
    def step(
        self,
        state: AlgorithmState,
        params: dict,
        settings: "Config",
    ) -> bool:
        """Execute one iteration of the SCP algorithm.

        This method solves a single convex subproblem, updates the algorithm
        state in place, and returns whether convergence criteria are met.

        Uses stored infrastructure (ocp, discretization_solver, etc.) with
        per-step params and settings to support runtime modifications.

        Args:
            state: Mutable algorithm state (modified in place)
            params: Problem parameters dictionary (may change between steps)
            settings: Configuration object (may change between steps)

        Returns:
            True if convergence criteria are satisfied, False otherwise.
        """
        raise NotImplementedError

    @abstractmethod
    def citation(self) -> List[str]:
        """Return BibTeX citations for this algorithm.

        Implementations should return a list of BibTeX entry strings for the
        papers that should be cited when using this algorithm.

        Returns:
            List of BibTeX citation strings.

        Example:
            Getting citations for an algorithm::

                algorithm = PenalizedTrustRegion()
                for bibtex in algorithm.citation():
                    print(bibtex)
        """
        raise NotImplementedError
citation() -> List[str] abstractmethod

Return BibTeX citations for this algorithm.

Implementations should return a list of BibTeX entry strings for the papers that should be cited when using this algorithm.

Returns:

Type Description
List[str]

List of BibTeX citation strings.

Example

Getting citations for an algorithm::

algorithm = PenalizedTrustRegion()
for bibtex in algorithm.citation():
    print(bibtex)
Source code in openscvx/algorithms/base.py
@abstractmethod
def citation(self) -> List[str]:
    """Return BibTeX citations for this algorithm.

    Implementations should return a list of BibTeX entry strings for the
    papers that should be cited when using this algorithm.

    Returns:
        List of BibTeX citation strings.

    Example:
        Getting citations for an algorithm::

            algorithm = PenalizedTrustRegion()
            for bibtex in algorithm.citation():
                print(bibtex)
    """
    raise NotImplementedError
initialize(solver: ConvexSolver, discretization_solver: callable, jax_constraints: LoweredJaxConstraints, emitter: callable, params: dict, settings: Config, discretization_solver_impulsive: Optional[Callable] = None) -> None abstractmethod

Initialize the algorithm and store compiled infrastructure.

This method stores immutable components and performs any setup required before the SCP loop begins (e.g., warm-starting solvers). The params and settings are passed for warm-start but may change between steps.

Parameters:

Name Type Description Default
solver ConvexSolver

Convex subproblem solver (e.g., CVXPySolver)

required
discretization_solver callable

Compiled discretization solver function

required
jax_constraints LoweredJaxConstraints

JIT-compiled JAX constraint functions

required
emitter callable

Callback for emitting iteration progress data

required
params dict

Problem parameters dictionary (for warm-start only)

required
settings Config

Configuration object (for warm-start only)

required
discretization_solver_impulsive Optional[Callable]

Optional solver for discrete/impulsive dynamics evaluated on (x_prop, u_discrete)

None
Source code in openscvx/algorithms/base.py
@abstractmethod
def initialize(
    self,
    solver: "ConvexSolver",
    discretization_solver: callable,
    jax_constraints: "LoweredJaxConstraints",
    emitter: callable,
    params: dict,
    settings: "Config",
    discretization_solver_impulsive: Optional[Callable] = None,
) -> None:
    """Initialize the algorithm and store compiled infrastructure.

    This method stores immutable components and performs any setup required
    before the SCP loop begins (e.g., warm-starting solvers). The params and
    settings are passed for warm-start but may change between steps.

    Args:
        solver: Convex subproblem solver (e.g., CVXPySolver)
        discretization_solver: Compiled discretization solver function
        jax_constraints: JIT-compiled JAX constraint functions
        emitter: Callback for emitting iteration progress data
        params: Problem parameters dictionary (for warm-start only)
        settings: Configuration object (for warm-start only)
        discretization_solver_impulsive: Optional solver for discrete/impulsive
            dynamics evaluated on ``(x_prop, u_discrete)``
    """
    raise NotImplementedError
step(state: AlgorithmState, params: dict, settings: Config) -> bool abstractmethod

Execute one iteration of the SCP algorithm.

This method solves a single convex subproblem, updates the algorithm state in place, and returns whether convergence criteria are met.

Uses stored infrastructure (ocp, discretization_solver, etc.) with per-step params and settings to support runtime modifications.

Parameters:

Name Type Description Default
state AlgorithmState

Mutable algorithm state (modified in place)

required
params dict

Problem parameters dictionary (may change between steps)

required
settings Config

Configuration object (may change between steps)

required

Returns:

Type Description
bool

True if convergence criteria are satisfied, False otherwise.

Source code in openscvx/algorithms/base.py
@abstractmethod
def step(
    self,
    state: AlgorithmState,
    params: dict,
    settings: "Config",
) -> bool:
    """Execute one iteration of the SCP algorithm.

    This method solves a single convex subproblem, updates the algorithm
    state in place, and returns whether convergence criteria are met.

    Uses stored infrastructure (ocp, discretization_solver, etc.) with
    per-step params and settings to support runtime modifications.

    Args:
        state: Mutable algorithm state (modified in place)
        params: Problem parameters dictionary (may change between steps)
        settings: Configuration object (may change between steps)

    Returns:
        True if convergence criteria are satisfied, False otherwise.
    """
    raise NotImplementedError

AlgorithmState dataclass

Mutable state for SCP iterations.

This dataclass holds all state that changes during the solve process. It stores only the evolving trajectory arrays, not the full State/Control objects which contain immutable configuration metadata.

Trajectory arrays are stored in history lists, with the current guess accessed via properties that return the latest entry.

A fresh instance is created for each solve, enabling easy reset functionality.

Attributes:

Name Type Description
k int

Current iteration number (starts at 1)

J_tr float

Current trust region cost

J_vb float

Current virtual buffer cost

J_vc float

Current virtual control cost

lam_prox ndarray

Current trust region weight (may adapt during solve)

lam_cost Union[float, ndarray]

Current cost weight (may relax during solve)

lam_vc Union[float, ndarray]

Current virtual control penalty weight

lam_vb_nodal ndarray

Current per-node nodal virtual buffer penalty weights

lam_vb_cross ndarray

Current cross-node virtual buffer penalty weights

n_x int

Number of states (for unpacking V vectors)

n_u int

Number of controls (for unpacking V vectors)

N int

Number of trajectory nodes (for unpacking V vectors)

X List[ndarray]

List of state trajectory iterates

U List[ndarray]

List of control trajectory iterates

discretizations List[DiscretizationResult]

List of unpacked discretization results

VC_history List[ndarray]

List of virtual control history

TR_history List[ndarray]

List of trust region history

A_bar_history List[ndarray]

List of state transition matrices

B_bar_history List[ndarray]

List of control influence matrices

C_bar_history List[ndarray]

List of control influence matrices for next node

x_prop_history List[ndarray]

List of propagated states

Source code in openscvx/algorithms/base.py
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
@dataclass
class AlgorithmState:
    """Mutable state for SCP iterations.

    This dataclass holds all state that changes during the solve process.
    It stores only the evolving trajectory arrays, not the full State/Control
    objects which contain immutable configuration metadata.

    Trajectory arrays are stored in history lists, with the current guess
    accessed via properties that return the latest entry.

    A fresh instance is created for each solve, enabling easy reset functionality.

    Attributes:
        k: Current iteration number (starts at 1)
        J_tr: Current trust region cost
        J_vb: Current virtual buffer cost
        J_vc: Current virtual control cost
        lam_prox: Current trust region weight (may adapt during solve)
        lam_cost: Current cost weight (may relax during solve)
        lam_vc: Current virtual control penalty weight
        lam_vb_nodal: Current per-node nodal virtual buffer penalty weights
        lam_vb_cross: Current cross-node virtual buffer penalty weights
        n_x: Number of states (for unpacking V vectors)
        n_u: Number of controls (for unpacking V vectors)
        N: Number of trajectory nodes (for unpacking V vectors)
        X: List of state trajectory iterates
        U: List of control trajectory iterates
        discretizations: List of unpacked discretization results
        VC_history: List of virtual control history
        TR_history: List of trust region history
        A_bar_history: List of state transition matrices
        B_bar_history: List of control influence matrices
        C_bar_history: List of control influence matrices for next node
        x_prop_history: List of propagated states
    """

    k: int
    J_tr: float
    J_vb: float
    J_vc: float
    n_x: int
    n_u: int
    N: int
    J_nonlin_history: List[float]
    J_lin_history: List[float]
    pred_reduction_history: List[float]
    actual_reduction_history: List[float]
    acceptance_ratio_history: List[float]
    X: List[np.ndarray] = field(default_factory=list)
    U: List[np.ndarray] = field(default_factory=list)
    discretizations: List[DiscretizationResult] = field(default_factory=list)
    VC_history: List[np.ndarray] = field(default_factory=list)
    TR_history: List[np.ndarray] = field(default_factory=list)
    lam_vc_history: List[Union[float, np.ndarray]] = field(default_factory=list)
    lam_cost_history: List[Union[float, np.ndarray]] = field(default_factory=list)
    lam_vb_nodal_history: List[np.ndarray] = field(default_factory=list)
    lam_vb_cross_history: List[np.ndarray] = field(default_factory=list)
    lam_prox_history: List[np.ndarray] = field(default_factory=list)
    x_full: List[np.ndarray] = field(default_factory=list)
    x_prop_full: List[np.ndarray] = field(default_factory=list)

    def accept_solution(self, cand: CandidateIterate) -> None:
        """Accept the given candidate iterate by updating the state in place."""
        if cand.x is None or cand.u is None:
            raise ValueError(
                "No candidate iterate to accept. Expected algorithm to set "
                "`cand.x` and `cand.u` before calling accept_solution()."
            )

        self.X.append(cand.x)
        self.U.append(cand.u)

        if cand.V is not None:
            if cand.W is not None:
                self.discretizations.append(
                    DiscretizationResult.from_VW(
                        cand.V,
                        cand.W,
                        n_x=self.n_x,
                        n_u=self.n_u,
                        N=self.N,
                    )
                )
            else:
                self.discretizations.append(
                    DiscretizationResult.from_V(
                        cand.V,
                        n_x=self.n_x,
                        n_u=self.n_u,
                        N=self.N,
                    )
                )
        if cand.VC is not None:
            self.VC_history.append(cand.VC)
        if cand.TR is not None:
            self.TR_history.append(cand.TR)

        if cand.lam_prox is not None:
            self.lam_prox_history.append(cand.lam_prox)
        if cand.lam_vc is not None:
            self.lam_vc_history.append(cand.lam_vc)
        if cand.lam_cost is not None:
            self.lam_cost_history.append(cand.lam_cost)
        if cand.lam_vb_nodal is not None:
            self.lam_vb_nodal_history.append(cand.lam_vb_nodal)
        if cand.lam_vb_cross is not None:
            self.lam_vb_cross_history.append(cand.lam_vb_cross)

        if cand.J_nonlin is not None:
            self.J_nonlin_history.append(cand.J_nonlin)
        if cand.J_lin is not None:
            self.J_lin_history.append(cand.J_lin)

    def reject_solution(self, cand: CandidateIterate) -> None:
        """Reject the current candidate and update only the trust-region weight.

        This is intended for autotuners that decide to *reject* a candidate
        iterate but still want to adapt the proximal (trust-region) weight
        for the next solve. The new trust region weight is taken from
        ``cand.lam_prox`` (shape ``(N, n_states + n_controls)``) and appended
        to the history. It does **not** modify trajectories or any other
        histories.

        Args:
            cand: The rejected candidate iterate; its ``lam_prox`` is recorded.
        """
        self.lam_prox_history.append(cand.lam_prox)

    @property
    def x(self) -> np.ndarray:
        """Get current state trajectory array.

        Returns:
            Current state trajectory guess (latest entry in history), shape (N, n_states)
        """
        return self.X[-1]

    @property
    def u(self) -> np.ndarray:
        """Get current control trajectory array.

        Returns:
            Current control trajectory guess (latest entry in history), shape (N, n_controls)
        """
        return self.U[-1]

    def add_discretization(self, V: np.ndarray) -> None:
        """Append a raw discretization matrix as an unpacked result."""
        self.discretizations.append(
            DiscretizationResult.from_V(V, n_x=self.n_x, n_u=self.n_u, N=self.N)
        )

    def add_impulsive_discretization(
        self,
        W: np.ndarray,
    ) -> None:
        """Attach impulsive discretization data to the latest discretization entry."""
        if not self.discretizations:
            raise ValueError(
                "Cannot attach impulsive discretization before adding the base discretization."
            )
        last = self.discretizations[-1]
        self.discretizations[-1] = DiscretizationResult.from_VW(
            V=last.V,
            W=W,
            n_x=self.n_x,
            n_u=self.n_u,
            N=self.N,
        )

    @property
    def V_history(self) -> List[np.ndarray]:
        """Backward-compatible view of raw discretization matrices.

        Note:
            This is a read-only view. Internal code should prefer
            ``state.discretizations``.
        """
        return [d.V for d in self.discretizations]

    def x_prop(self, index: int = -1) -> np.ndarray:
        """Extract propagated state trajectory from the discretization history.

        Args:
            index: Index into V_history (default: -1 for latest entry)

        Returns:
            Propagated state trajectory x_prop with shape (N-1, n_x), or None if no V_history

        Example:
            After running an iteration, access the propagated states::

                problem.step()
                x_prop = problem.state.x_prop()  # Shape (N-1, n_x), latest
                x_prop_prev = problem.state.x_prop(-2)  # Previous iteration
        """
        if not self.discretizations:
            return None
        return self.discretizations[index].x_prop

    def A_d(self, index: int = -1) -> np.ndarray:
        """Extract discretized state transition matrix from discretizations.

        Args:
            index: Index into V_history (default: -1 for latest entry)

        Returns:
            Discretized state Jacobian A_d with shape (N-1, n_x, n_x), or None if no V_history

        Example:
            After running an iteration, access linearization matrices::

                problem.step()
                A_d = problem.state.A_d()  # Shape (N-1, n_x, n_x), latest
                A_d_prev = problem.state.A_d(-2)  # Previous iteration
        """
        if not self.discretizations:
            return None
        return self.discretizations[index].A_d

    def B_d(self, index: int = -1) -> np.ndarray:
        """Extract discretized control influence matrix (current node).

        Args:
            index: Index into discretization history (default: -1 for latest entry)

        Returns:
            Discretized control Jacobian B_d with shape (N-1, n_x, n_u), or None if empty.

        Example:
            After running an iteration, access linearization matrices::

                problem.step()
                B_d = problem.state.B_d()  # Shape (N-1, n_x, n_u), latest
                B_d_prev = problem.state.B_d(-2)  # Previous iteration
        """
        if not self.discretizations:
            return None
        return self.discretizations[index].B_d

    def C_d(self, index: int = -1) -> np.ndarray:
        """Extract discretized control influence matrix (next node).

        Args:
            index: Index into discretization history (default: -1 for latest entry)

        Returns:
            Discretized control Jacobian C_d with shape (N-1, n_x, n_u), or None if empty.

        Example:
            After running an iteration, access linearization matrices::

                problem.step()
                C_d = problem.state.C_d()  # Shape (N-1, n_x, n_u), latest
                C_d_prev = problem.state.C_d(-2)  # Previous iteration
        """
        if not self.discretizations:
            return None
        return self.discretizations[index].C_d

    def x_prop_plus(self, index: int = -1) -> np.ndarray:
        """Extract discrete dynamics evaluated at x_prop."""
        if not self.discretizations:
            return None
        return self.discretizations[index].x_prop_plus

    def D_d(self, index: int = -1) -> np.ndarray:
        """Extract Jacobian of x_prop_plus w.r.t. x_prop."""
        if not self.discretizations:
            return None
        return self.discretizations[index].D_d

    def E_d(self, index: int = -1) -> np.ndarray:
        """Extract Jacobian of x_prop_plus w.r.t. discrete controls."""
        if not self.discretizations:
            return None
        return self.discretizations[index].E_d

    @property
    def lam_prox(self) -> np.ndarray:
        """Get current trust region weight.

        Returns:
            Array of shape ``(N, n_states + n_controls)``.
        """
        if not self.lam_prox_history:
            raise ValueError("lam_prox_history is empty. Initialize state using from_settings().")
        return self.lam_prox_history[-1]

    @property
    def lam_cost(self) -> Union[float, np.ndarray]:
        """Get current cost weight.

        Returns:
            Current cost weight (latest entry in lam_cost_history).
            Scalar or array of shape ``(n_states,)`` for per-state weighting.
        """
        if not self.lam_cost_history:
            raise ValueError("lam_cost_history is empty. Initialize state using from_settings().")
        return self.lam_cost_history[-1]

    @property
    def lam_vc(self) -> Union[float, np.ndarray]:
        """Get current virtual control penalty weight.

        Returns:
            Current virtual control penalty weight (latest entry in lam_vc_history)
        """
        if not self.lam_vc_history:
            raise ValueError("lam_vc_history is empty. Initialize state using from_settings().")
        return self.lam_vc_history[-1]

    @property
    def lam_vb_nodal(self) -> np.ndarray:
        """Get current virtual buffer penalty weights for nodal constraints.

        Returns:
            Array of shape ``(N, n_nodal_constraints)``.
        """
        if not self.lam_vb_nodal_history:
            raise ValueError(
                "lam_vb_nodal_history is empty. Initialize state using from_settings()."
            )
        return self.lam_vb_nodal_history[-1]

    @property
    def lam_vb_cross(self) -> np.ndarray:
        """Get current virtual buffer penalty weights for cross-node constraints.

        Returns:
            Array of shape ``(n_cross_node_constraints,)``.
        """
        if not self.lam_vb_cross_history:
            raise ValueError(
                "lam_vb_cross_history is empty. Initialize state using from_settings()."
            )
        return self.lam_vb_cross_history[-1]

    @classmethod
    def from_settings(
        cls,
        settings: "Config",
        weights: "Weights",
    ) -> "AlgorithmState":
        """Create initial algorithm state from configuration.

        Copies only the trajectory arrays from settings, leaving all metadata
        (bounds, boundary conditions, etc.) in the original settings object.

        Args:
            settings: Configuration object containing initial guesses and SCP parameters
            weights: Initial weights from the algorithm.
                ``lam_vc`` is expanded to an ``(N-1, n_states)`` array here
                (scalar or per-state values are broadcast).

        Returns:
            Fresh AlgorithmState initialized from settings with copied arrays
        """
        n = settings.sim.n
        n_states = settings.sim.n_states
        n_controls = settings.sim.n_controls
        n_total = n_states + n_controls
        lam_vc_array = np.ones((n - 1, n_states)) * weights.lam_vc

        # Expand lam_prox to (N, n_states + n_controls) array
        lam_prox_array = np.ones((n, n_total)) * weights.lam_prox

        # Expand scalar lam_cost to per-state array
        if isinstance(weights.lam_cost, np.ndarray):
            lam_cost_init = weights.lam_cost.copy()
        else:
            lam_cost_init = np.full(n_states, weights.lam_cost)

        return cls(
            k=1,
            J_tr=1e2,
            J_vb=1e2,
            J_vc=1e2,
            n_x=n_states,
            n_u=settings.sim.n_controls,
            N=n,
            J_nonlin_history=[],
            J_lin_history=[],
            pred_reduction_history=[],
            actual_reduction_history=[],
            acceptance_ratio_history=[],
            X=[settings.sim.x.guess.copy()],
            U=[settings.sim.u.guess.copy()],
            discretizations=[],
            VC_history=[],
            TR_history=[],
            lam_vc_history=[lam_vc_array],
            lam_cost_history=[lam_cost_init],
            lam_vb_nodal_history=[weights.lam_vb_nodal.copy()],
            lam_vb_cross_history=[weights.lam_vb_cross.copy()],
            lam_prox_history=[lam_prox_array],
        )
V_history: List[np.ndarray] property

Backward-compatible view of raw discretization matrices.

Note

This is a read-only view. Internal code should prefer state.discretizations.

lam_cost: Union[float, np.ndarray] property

Get current cost weight.

Returns:

Type Description
Union[float, ndarray]

Current cost weight (latest entry in lam_cost_history).

Union[float, ndarray]

Scalar or array of shape (n_states,) for per-state weighting.

lam_prox: np.ndarray property

Get current trust region weight.

Returns:

Type Description
ndarray

Array of shape (N, n_states + n_controls).

lam_vb_cross: np.ndarray property

Get current virtual buffer penalty weights for cross-node constraints.

Returns:

Type Description
ndarray

Array of shape (n_cross_node_constraints,).

lam_vb_nodal: np.ndarray property

Get current virtual buffer penalty weights for nodal constraints.

Returns:

Type Description
ndarray

Array of shape (N, n_nodal_constraints).

lam_vc: Union[float, np.ndarray] property

Get current virtual control penalty weight.

Returns:

Type Description
Union[float, ndarray]

Current virtual control penalty weight (latest entry in lam_vc_history)

u: np.ndarray property

Get current control trajectory array.

Returns:

Type Description
ndarray

Current control trajectory guess (latest entry in history), shape (N, n_controls)

x: np.ndarray property

Get current state trajectory array.

Returns:

Type Description
ndarray

Current state trajectory guess (latest entry in history), shape (N, n_states)

A_d(index: int = -1) -> np.ndarray

Extract discretized state transition matrix from discretizations.

Parameters:

Name Type Description Default
index int

Index into V_history (default: -1 for latest entry)

-1

Returns:

Type Description
ndarray

Discretized state Jacobian A_d with shape (N-1, n_x, n_x), or None if no V_history

Example

After running an iteration, access linearization matrices::

problem.step()
A_d = problem.state.A_d()  # Shape (N-1, n_x, n_x), latest
A_d_prev = problem.state.A_d(-2)  # Previous iteration
Source code in openscvx/algorithms/base.py
def A_d(self, index: int = -1) -> np.ndarray:
    """Extract discretized state transition matrix from discretizations.

    Args:
        index: Index into V_history (default: -1 for latest entry)

    Returns:
        Discretized state Jacobian A_d with shape (N-1, n_x, n_x), or None if no V_history

    Example:
        After running an iteration, access linearization matrices::

            problem.step()
            A_d = problem.state.A_d()  # Shape (N-1, n_x, n_x), latest
            A_d_prev = problem.state.A_d(-2)  # Previous iteration
    """
    if not self.discretizations:
        return None
    return self.discretizations[index].A_d
B_d(index: int = -1) -> np.ndarray

Extract discretized control influence matrix (current node).

Parameters:

Name Type Description Default
index int

Index into discretization history (default: -1 for latest entry)

-1

Returns:

Type Description
ndarray

Discretized control Jacobian B_d with shape (N-1, n_x, n_u), or None if empty.

Example

After running an iteration, access linearization matrices::

problem.step()
B_d = problem.state.B_d()  # Shape (N-1, n_x, n_u), latest
B_d_prev = problem.state.B_d(-2)  # Previous iteration
Source code in openscvx/algorithms/base.py
def B_d(self, index: int = -1) -> np.ndarray:
    """Extract discretized control influence matrix (current node).

    Args:
        index: Index into discretization history (default: -1 for latest entry)

    Returns:
        Discretized control Jacobian B_d with shape (N-1, n_x, n_u), or None if empty.

    Example:
        After running an iteration, access linearization matrices::

            problem.step()
            B_d = problem.state.B_d()  # Shape (N-1, n_x, n_u), latest
            B_d_prev = problem.state.B_d(-2)  # Previous iteration
    """
    if not self.discretizations:
        return None
    return self.discretizations[index].B_d
C_d(index: int = -1) -> np.ndarray

Extract discretized control influence matrix (next node).

Parameters:

Name Type Description Default
index int

Index into discretization history (default: -1 for latest entry)

-1

Returns:

Type Description
ndarray

Discretized control Jacobian C_d with shape (N-1, n_x, n_u), or None if empty.

Example

After running an iteration, access linearization matrices::

problem.step()
C_d = problem.state.C_d()  # Shape (N-1, n_x, n_u), latest
C_d_prev = problem.state.C_d(-2)  # Previous iteration
Source code in openscvx/algorithms/base.py
def C_d(self, index: int = -1) -> np.ndarray:
    """Extract discretized control influence matrix (next node).

    Args:
        index: Index into discretization history (default: -1 for latest entry)

    Returns:
        Discretized control Jacobian C_d with shape (N-1, n_x, n_u), or None if empty.

    Example:
        After running an iteration, access linearization matrices::

            problem.step()
            C_d = problem.state.C_d()  # Shape (N-1, n_x, n_u), latest
            C_d_prev = problem.state.C_d(-2)  # Previous iteration
    """
    if not self.discretizations:
        return None
    return self.discretizations[index].C_d
D_d(index: int = -1) -> np.ndarray

Extract Jacobian of x_prop_plus w.r.t. x_prop.

Source code in openscvx/algorithms/base.py
def D_d(self, index: int = -1) -> np.ndarray:
    """Extract Jacobian of x_prop_plus w.r.t. x_prop."""
    if not self.discretizations:
        return None
    return self.discretizations[index].D_d
E_d(index: int = -1) -> np.ndarray

Extract Jacobian of x_prop_plus w.r.t. discrete controls.

Source code in openscvx/algorithms/base.py
def E_d(self, index: int = -1) -> np.ndarray:
    """Extract Jacobian of x_prop_plus w.r.t. discrete controls."""
    if not self.discretizations:
        return None
    return self.discretizations[index].E_d
accept_solution(cand: CandidateIterate) -> None

Accept the given candidate iterate by updating the state in place.

Source code in openscvx/algorithms/base.py
def accept_solution(self, cand: CandidateIterate) -> None:
    """Accept the given candidate iterate by updating the state in place."""
    if cand.x is None or cand.u is None:
        raise ValueError(
            "No candidate iterate to accept. Expected algorithm to set "
            "`cand.x` and `cand.u` before calling accept_solution()."
        )

    self.X.append(cand.x)
    self.U.append(cand.u)

    if cand.V is not None:
        if cand.W is not None:
            self.discretizations.append(
                DiscretizationResult.from_VW(
                    cand.V,
                    cand.W,
                    n_x=self.n_x,
                    n_u=self.n_u,
                    N=self.N,
                )
            )
        else:
            self.discretizations.append(
                DiscretizationResult.from_V(
                    cand.V,
                    n_x=self.n_x,
                    n_u=self.n_u,
                    N=self.N,
                )
            )
    if cand.VC is not None:
        self.VC_history.append(cand.VC)
    if cand.TR is not None:
        self.TR_history.append(cand.TR)

    if cand.lam_prox is not None:
        self.lam_prox_history.append(cand.lam_prox)
    if cand.lam_vc is not None:
        self.lam_vc_history.append(cand.lam_vc)
    if cand.lam_cost is not None:
        self.lam_cost_history.append(cand.lam_cost)
    if cand.lam_vb_nodal is not None:
        self.lam_vb_nodal_history.append(cand.lam_vb_nodal)
    if cand.lam_vb_cross is not None:
        self.lam_vb_cross_history.append(cand.lam_vb_cross)

    if cand.J_nonlin is not None:
        self.J_nonlin_history.append(cand.J_nonlin)
    if cand.J_lin is not None:
        self.J_lin_history.append(cand.J_lin)
add_discretization(V: np.ndarray) -> None

Append a raw discretization matrix as an unpacked result.

Source code in openscvx/algorithms/base.py
def add_discretization(self, V: np.ndarray) -> None:
    """Append a raw discretization matrix as an unpacked result."""
    self.discretizations.append(
        DiscretizationResult.from_V(V, n_x=self.n_x, n_u=self.n_u, N=self.N)
    )
add_impulsive_discretization(W: np.ndarray) -> None

Attach impulsive discretization data to the latest discretization entry.

Source code in openscvx/algorithms/base.py
def add_impulsive_discretization(
    self,
    W: np.ndarray,
) -> None:
    """Attach impulsive discretization data to the latest discretization entry."""
    if not self.discretizations:
        raise ValueError(
            "Cannot attach impulsive discretization before adding the base discretization."
        )
    last = self.discretizations[-1]
    self.discretizations[-1] = DiscretizationResult.from_VW(
        V=last.V,
        W=W,
        n_x=self.n_x,
        n_u=self.n_u,
        N=self.N,
    )
from_settings(settings: Config, weights: Weights) -> AlgorithmState classmethod

Create initial algorithm state from configuration.

Copies only the trajectory arrays from settings, leaving all metadata (bounds, boundary conditions, etc.) in the original settings object.

Parameters:

Name Type Description Default
settings Config

Configuration object containing initial guesses and SCP parameters

required
weights Weights

Initial weights from the algorithm. lam_vc is expanded to an (N-1, n_states) array here (scalar or per-state values are broadcast).

required

Returns:

Type Description
AlgorithmState

Fresh AlgorithmState initialized from settings with copied arrays

Source code in openscvx/algorithms/base.py
@classmethod
def from_settings(
    cls,
    settings: "Config",
    weights: "Weights",
) -> "AlgorithmState":
    """Create initial algorithm state from configuration.

    Copies only the trajectory arrays from settings, leaving all metadata
    (bounds, boundary conditions, etc.) in the original settings object.

    Args:
        settings: Configuration object containing initial guesses and SCP parameters
        weights: Initial weights from the algorithm.
            ``lam_vc`` is expanded to an ``(N-1, n_states)`` array here
            (scalar or per-state values are broadcast).

    Returns:
        Fresh AlgorithmState initialized from settings with copied arrays
    """
    n = settings.sim.n
    n_states = settings.sim.n_states
    n_controls = settings.sim.n_controls
    n_total = n_states + n_controls
    lam_vc_array = np.ones((n - 1, n_states)) * weights.lam_vc

    # Expand lam_prox to (N, n_states + n_controls) array
    lam_prox_array = np.ones((n, n_total)) * weights.lam_prox

    # Expand scalar lam_cost to per-state array
    if isinstance(weights.lam_cost, np.ndarray):
        lam_cost_init = weights.lam_cost.copy()
    else:
        lam_cost_init = np.full(n_states, weights.lam_cost)

    return cls(
        k=1,
        J_tr=1e2,
        J_vb=1e2,
        J_vc=1e2,
        n_x=n_states,
        n_u=settings.sim.n_controls,
        N=n,
        J_nonlin_history=[],
        J_lin_history=[],
        pred_reduction_history=[],
        actual_reduction_history=[],
        acceptance_ratio_history=[],
        X=[settings.sim.x.guess.copy()],
        U=[settings.sim.u.guess.copy()],
        discretizations=[],
        VC_history=[],
        TR_history=[],
        lam_vc_history=[lam_vc_array],
        lam_cost_history=[lam_cost_init],
        lam_vb_nodal_history=[weights.lam_vb_nodal.copy()],
        lam_vb_cross_history=[weights.lam_vb_cross.copy()],
        lam_prox_history=[lam_prox_array],
    )
reject_solution(cand: CandidateIterate) -> None

Reject the current candidate and update only the trust-region weight.

This is intended for autotuners that decide to reject a candidate iterate but still want to adapt the proximal (trust-region) weight for the next solve. The new trust region weight is taken from cand.lam_prox (shape (N, n_states + n_controls)) and appended to the history. It does not modify trajectories or any other histories.

Parameters:

Name Type Description Default
cand CandidateIterate

The rejected candidate iterate; its lam_prox is recorded.

required
Source code in openscvx/algorithms/base.py
def reject_solution(self, cand: CandidateIterate) -> None:
    """Reject the current candidate and update only the trust-region weight.

    This is intended for autotuners that decide to *reject* a candidate
    iterate but still want to adapt the proximal (trust-region) weight
    for the next solve. The new trust region weight is taken from
    ``cand.lam_prox`` (shape ``(N, n_states + n_controls)``) and appended
    to the history. It does **not** modify trajectories or any other
    histories.

    Args:
        cand: The rejected candidate iterate; its ``lam_prox`` is recorded.
    """
    self.lam_prox_history.append(cand.lam_prox)
x_prop(index: int = -1) -> np.ndarray

Extract propagated state trajectory from the discretization history.

Parameters:

Name Type Description Default
index int

Index into V_history (default: -1 for latest entry)

-1

Returns:

Type Description
ndarray

Propagated state trajectory x_prop with shape (N-1, n_x), or None if no V_history

Example

After running an iteration, access the propagated states::

problem.step()
x_prop = problem.state.x_prop()  # Shape (N-1, n_x), latest
x_prop_prev = problem.state.x_prop(-2)  # Previous iteration
Source code in openscvx/algorithms/base.py
def x_prop(self, index: int = -1) -> np.ndarray:
    """Extract propagated state trajectory from the discretization history.

    Args:
        index: Index into V_history (default: -1 for latest entry)

    Returns:
        Propagated state trajectory x_prop with shape (N-1, n_x), or None if no V_history

    Example:
        After running an iteration, access the propagated states::

            problem.step()
            x_prop = problem.state.x_prop()  # Shape (N-1, n_x), latest
            x_prop_prev = problem.state.x_prop(-2)  # Previous iteration
    """
    if not self.discretizations:
        return None
    return self.discretizations[index].x_prop
x_prop_plus(index: int = -1) -> np.ndarray

Extract discrete dynamics evaluated at x_prop.

Source code in openscvx/algorithms/base.py
def x_prop_plus(self, index: int = -1) -> np.ndarray:
    """Extract discrete dynamics evaluated at x_prop."""
    if not self.discretizations:
        return None
    return self.discretizations[index].x_prop_plus

AutotuningBase

Bases: ABC

Base class for autotuning methods in SCP algorithms.

This class provides common functionality for calculating costs and penalties that are shared across different autotuning strategies (e.g., Penalized Trust Region, Augmented Lagrangian).

Subclasses must implement the update_weights method.

Class Attributes

COLUMNS: List of Column specs for autotuner-specific metrics to display. Subclasses override this to add their own columns.

Source code in openscvx/algorithms/base.py
class AutotuningBase(ABC):
    """Base class for autotuning methods in SCP algorithms.

    This class provides common functionality for calculating costs and penalties
    that are shared across different autotuning strategies (e.g., Penalized Trust
    Region, Augmented Lagrangian).

    Subclasses must implement the ``update_weights`` method.

    Class Attributes:
        COLUMNS: List of Column specs for autotuner-specific metrics to display.
            Subclasses override this to add their own columns.
    """

    COLUMNS: List[Column] = []

    @staticmethod
    def calculate_cost_from_state(
        x: np.ndarray,
        settings: "Config",
        lam_cost: Union[float, np.ndarray] = 1.0,
    ) -> float:
        """Calculate cost from state vector based on final_type and initial_type.

        Args:
            x: State trajectory array (n_nodes, n_states)
            settings: Configuration object containing state types
            lam_cost: Per-state cost weight. Scalar (applied uniformly) or
                array of shape ``(n_states,)`` for per-state weighting.

        Returns:
            float: Computed cost (weighted by lam_cost)
        """
        scaled_x = (settings.sim.inv_S_x @ (x.T - settings.sim.c_x[:, None])).T
        lam = np.asarray(lam_cost)
        cost = 0.0
        for i in range(settings.sim.n_states):
            w = float(lam[i]) if lam.ndim > 0 else float(lam)
            if settings.sim.x.final_type[i] == "Minimize":
                cost += w * scaled_x[-1, i]
            if settings.sim.x.final_type[i] == "Maximize":
                cost -= w * scaled_x[-1, i]
            if settings.sim.x.initial_type[i] == "Minimize":
                cost += w * scaled_x[0, i]
            if settings.sim.x.initial_type[i] == "Maximize":
                cost -= w * scaled_x[0, i]
        return cost

    @staticmethod
    def calculate_nonlinear_penalty(
        x_prop: np.ndarray,
        x_bar: np.ndarray,
        u_bar: np.ndarray,
        lam_vc: np.ndarray,
        lam_vb_nodal: np.ndarray,
        lam_vb_cross: np.ndarray,
        lam_cost: Union[float, np.ndarray],
        nodal_constraints: "LoweredJaxConstraints",
        params: dict,
        settings: "Config",
    ) -> Tuple[float, float, float]:
        """Calculate nonlinear penalty components.

        This method computes three penalty components:
        1. Cost penalty: weighted original cost
        2. Virtual control penalty: penalty for dynamics violations
        3. Nodal penalty: penalty for constraint violations

        Args:
            x_prop: Propagated state (n_nodes-1, n_states)
            x_bar: Previous iteration state (n_nodes, n_states)
            u_bar: Solution control (n_nodes, n_controls)
            lam_vc: Virtual control weight (scalar or matrix)
            lam_vb_nodal: Virtual buffer penalty weights for nodal
                constraints, shape ``(N, n_nodal)``.
            lam_vb_cross: Virtual buffer penalty weights for cross-node
                constraints, shape ``(n_cross,)``.
            lam_cost: Cost weight. Scalar (applied uniformly) or
                array of shape ``(n_states,)`` for per-state weighting.
            nodal_constraints: Lowered JAX constraints
            params: Dictionary of problem parameters
            settings: Configuration object

        Returns:
            Tuple of (nonlinear_cost, nonlinear_penalty, nodal_penalty):
                - nonlinear_cost: Weighted cost component
                - nonlinear_penalty: Virtual control penalty
                - nodal_penalty: Constraint violation penalty
        """
        nodal_penalty = 0.0

        # Evaluate nodal constraints
        for idx, constraint in enumerate(nodal_constraints.nodal):
            # Nodal constraint function is vmapped: func(x, u, node, params)
            # When called with arrays, it evaluates at all nodes
            g = constraint.func(x_bar, u_bar, 0, params)
            # Only sum violations at nodes where constraint is enforced
            if constraint.nodes is not None:
                nodes_array = np.array(constraint.nodes)
                g_filtered = g[nodes_array]
                w = lam_vb_nodal[nodes_array, idx]
            else:
                g_filtered = g
                w = lam_vb_nodal[:, idx]
            nodal_penalty += np.sum(w * np.maximum(0, g_filtered))

        # Evaluate cross-node constraints
        for idx, constraint in enumerate(nodal_constraints.cross_node):
            w = lam_vb_cross[idx]
            # Cross-node constraint function signature: func(X, U, params)
            # No node argument - operates on full trajectory
            g = constraint.func(x_bar, u_bar, params)
            # Cross-node constraints return scalar or array, sum all violations
            nodal_penalty += w * np.sum(np.maximum(0, g))

        # lam_cost weighting is applied inside calculate_cost_from_state,
        # so the returned cost is already weighted (no outer multiplication).
        cost = AutotuningBase.calculate_cost_from_state(x_bar, settings, lam_cost)
        x_diff = settings.sim.inv_S_x @ (x_bar[1:, :] - x_prop).T

        return cost, np.sum(lam_vc * np.abs(x_diff.T)), nodal_penalty

    @abstractmethod
    def update_weights(
        self,
        state: "AlgorithmState",
        candidate: "CandidateIterate",
        nodal_constraints: "LoweredJaxConstraints",
        settings: "Config",
        params: dict,
        weights: "Weights",
    ) -> str:
        """Update SCP weights and cost parameters based on iteration state.

        This method is called each iteration to adapt weights based on the
        current solution quality and constraint satisfaction.

        Args:
            state: Solver state containing current weight values (mutated in place)
            candidate: Candidate iterate from the current subproblem solve
            nodal_constraints: Lowered JAX constraints
            settings: Configuration object containing adaptation parameters
            params: Dictionary of problem parameters
            weights: Initial weights from the algorithm

        Returns:
            str: Adaptive state string describing the update action (e.g., "Accept Lower")
        """

        pass
calculate_cost_from_state(x: np.ndarray, settings: Config, lam_cost: Union[float, np.ndarray] = 1.0) -> float staticmethod

Calculate cost from state vector based on final_type and initial_type.

Parameters:

Name Type Description Default
x ndarray

State trajectory array (n_nodes, n_states)

required
settings Config

Configuration object containing state types

required
lam_cost Union[float, ndarray]

Per-state cost weight. Scalar (applied uniformly) or array of shape (n_states,) for per-state weighting.

1.0

Returns:

Name Type Description
float float

Computed cost (weighted by lam_cost)

Source code in openscvx/algorithms/base.py
@staticmethod
def calculate_cost_from_state(
    x: np.ndarray,
    settings: "Config",
    lam_cost: Union[float, np.ndarray] = 1.0,
) -> float:
    """Calculate cost from state vector based on final_type and initial_type.

    Args:
        x: State trajectory array (n_nodes, n_states)
        settings: Configuration object containing state types
        lam_cost: Per-state cost weight. Scalar (applied uniformly) or
            array of shape ``(n_states,)`` for per-state weighting.

    Returns:
        float: Computed cost (weighted by lam_cost)
    """
    scaled_x = (settings.sim.inv_S_x @ (x.T - settings.sim.c_x[:, None])).T
    lam = np.asarray(lam_cost)
    cost = 0.0
    for i in range(settings.sim.n_states):
        w = float(lam[i]) if lam.ndim > 0 else float(lam)
        if settings.sim.x.final_type[i] == "Minimize":
            cost += w * scaled_x[-1, i]
        if settings.sim.x.final_type[i] == "Maximize":
            cost -= w * scaled_x[-1, i]
        if settings.sim.x.initial_type[i] == "Minimize":
            cost += w * scaled_x[0, i]
        if settings.sim.x.initial_type[i] == "Maximize":
            cost -= w * scaled_x[0, i]
    return cost
calculate_nonlinear_penalty(x_prop: np.ndarray, x_bar: np.ndarray, u_bar: np.ndarray, lam_vc: np.ndarray, lam_vb_nodal: np.ndarray, lam_vb_cross: np.ndarray, lam_cost: Union[float, np.ndarray], nodal_constraints: LoweredJaxConstraints, params: dict, settings: Config) -> Tuple[float, float, float] staticmethod

Calculate nonlinear penalty components.

This method computes three penalty components: 1. Cost penalty: weighted original cost 2. Virtual control penalty: penalty for dynamics violations 3. Nodal penalty: penalty for constraint violations

Parameters:

Name Type Description Default
x_prop ndarray

Propagated state (n_nodes-1, n_states)

required
x_bar ndarray

Previous iteration state (n_nodes, n_states)

required
u_bar ndarray

Solution control (n_nodes, n_controls)

required
lam_vc ndarray

Virtual control weight (scalar or matrix)

required
lam_vb_nodal ndarray

Virtual buffer penalty weights for nodal constraints, shape (N, n_nodal).

required
lam_vb_cross ndarray

Virtual buffer penalty weights for cross-node constraints, shape (n_cross,).

required
lam_cost Union[float, ndarray]

Cost weight. Scalar (applied uniformly) or array of shape (n_states,) for per-state weighting.

required
nodal_constraints LoweredJaxConstraints

Lowered JAX constraints

required
params dict

Dictionary of problem parameters

required
settings Config

Configuration object

required

Returns:

Type Description
Tuple[float, float, float]

Tuple of (nonlinear_cost, nonlinear_penalty, nodal_penalty): - nonlinear_cost: Weighted cost component - nonlinear_penalty: Virtual control penalty - nodal_penalty: Constraint violation penalty

Source code in openscvx/algorithms/base.py
@staticmethod
def calculate_nonlinear_penalty(
    x_prop: np.ndarray,
    x_bar: np.ndarray,
    u_bar: np.ndarray,
    lam_vc: np.ndarray,
    lam_vb_nodal: np.ndarray,
    lam_vb_cross: np.ndarray,
    lam_cost: Union[float, np.ndarray],
    nodal_constraints: "LoweredJaxConstraints",
    params: dict,
    settings: "Config",
) -> Tuple[float, float, float]:
    """Calculate nonlinear penalty components.

    This method computes three penalty components:
    1. Cost penalty: weighted original cost
    2. Virtual control penalty: penalty for dynamics violations
    3. Nodal penalty: penalty for constraint violations

    Args:
        x_prop: Propagated state (n_nodes-1, n_states)
        x_bar: Previous iteration state (n_nodes, n_states)
        u_bar: Solution control (n_nodes, n_controls)
        lam_vc: Virtual control weight (scalar or matrix)
        lam_vb_nodal: Virtual buffer penalty weights for nodal
            constraints, shape ``(N, n_nodal)``.
        lam_vb_cross: Virtual buffer penalty weights for cross-node
            constraints, shape ``(n_cross,)``.
        lam_cost: Cost weight. Scalar (applied uniformly) or
            array of shape ``(n_states,)`` for per-state weighting.
        nodal_constraints: Lowered JAX constraints
        params: Dictionary of problem parameters
        settings: Configuration object

    Returns:
        Tuple of (nonlinear_cost, nonlinear_penalty, nodal_penalty):
            - nonlinear_cost: Weighted cost component
            - nonlinear_penalty: Virtual control penalty
            - nodal_penalty: Constraint violation penalty
    """
    nodal_penalty = 0.0

    # Evaluate nodal constraints
    for idx, constraint in enumerate(nodal_constraints.nodal):
        # Nodal constraint function is vmapped: func(x, u, node, params)
        # When called with arrays, it evaluates at all nodes
        g = constraint.func(x_bar, u_bar, 0, params)
        # Only sum violations at nodes where constraint is enforced
        if constraint.nodes is not None:
            nodes_array = np.array(constraint.nodes)
            g_filtered = g[nodes_array]
            w = lam_vb_nodal[nodes_array, idx]
        else:
            g_filtered = g
            w = lam_vb_nodal[:, idx]
        nodal_penalty += np.sum(w * np.maximum(0, g_filtered))

    # Evaluate cross-node constraints
    for idx, constraint in enumerate(nodal_constraints.cross_node):
        w = lam_vb_cross[idx]
        # Cross-node constraint function signature: func(X, U, params)
        # No node argument - operates on full trajectory
        g = constraint.func(x_bar, u_bar, params)
        # Cross-node constraints return scalar or array, sum all violations
        nodal_penalty += w * np.sum(np.maximum(0, g))

    # lam_cost weighting is applied inside calculate_cost_from_state,
    # so the returned cost is already weighted (no outer multiplication).
    cost = AutotuningBase.calculate_cost_from_state(x_bar, settings, lam_cost)
    x_diff = settings.sim.inv_S_x @ (x_bar[1:, :] - x_prop).T

    return cost, np.sum(lam_vc * np.abs(x_diff.T)), nodal_penalty
update_weights(state: AlgorithmState, candidate: CandidateIterate, nodal_constraints: LoweredJaxConstraints, settings: Config, params: dict, weights: Weights) -> str abstractmethod

Update SCP weights and cost parameters based on iteration state.

This method is called each iteration to adapt weights based on the current solution quality and constraint satisfaction.

Parameters:

Name Type Description Default
state AlgorithmState

Solver state containing current weight values (mutated in place)

required
candidate CandidateIterate

Candidate iterate from the current subproblem solve

required
nodal_constraints LoweredJaxConstraints

Lowered JAX constraints

required
settings Config

Configuration object containing adaptation parameters

required
params dict

Dictionary of problem parameters

required
weights Weights

Initial weights from the algorithm

required

Returns:

Name Type Description
str str

Adaptive state string describing the update action (e.g., "Accept Lower")

Source code in openscvx/algorithms/base.py
@abstractmethod
def update_weights(
    self,
    state: "AlgorithmState",
    candidate: "CandidateIterate",
    nodal_constraints: "LoweredJaxConstraints",
    settings: "Config",
    params: dict,
    weights: "Weights",
) -> str:
    """Update SCP weights and cost parameters based on iteration state.

    This method is called each iteration to adapt weights based on the
    current solution quality and constraint satisfaction.

    Args:
        state: Solver state containing current weight values (mutated in place)
        candidate: Candidate iterate from the current subproblem solve
        nodal_constraints: Lowered JAX constraints
        settings: Configuration object containing adaptation parameters
        params: Dictionary of problem parameters
        weights: Initial weights from the algorithm

    Returns:
        str: Adaptive state string describing the update action (e.g., "Accept Lower")
    """

    pass

DiscretizationResult dataclass

Unpacked discretization data from a multi-shot discretization matrix.

The discretization solver returns a matrix V that stores multiple blocks (propagated state and linearization matrices) across nodes/time. Historically, we stored the raw V matrices and re-unpacked them repeatedly via slicing. This dataclass unpacks once and makes access trivial.

Source code in openscvx/algorithms/base.py
@dataclass(frozen=True, slots=True)
class DiscretizationResult:
    """Unpacked discretization data from a multi-shot discretization matrix.

    The discretization solver returns a matrix ``V`` that stores multiple blocks
    (propagated state and linearization matrices) across nodes/time. Historically,
    we stored the raw ``V`` matrices and re-unpacked them repeatedly via slicing.
    This dataclass unpacks once and makes access trivial.
    """

    V: np.ndarray  # raw V matrix, shape: (flattened_size, n_timesteps)
    x_prop: np.ndarray  # (N-1, n_x)
    A_d: np.ndarray  # (N-1, n_x, n_x)
    B_d: np.ndarray  # (N-1, n_x, n_u)
    C_d: np.ndarray  # (N-1, n_x, n_u)
    x_prop_plus: Optional[np.ndarray] = None  # (N, n_x), discrete dynamics on node states
    D_d: Optional[np.ndarray] = None  # (N, n_x, n_x), d(x_prop_plus)/d(x_node)
    E_d: Optional[np.ndarray] = None  # (N, n_x, n_u), d(x_prop_plus)/d(u_node)

    @classmethod
    def from_V(
        cls,
        V: np.ndarray,
        n_x: int,
        n_u: int,
        N: int,
    ) -> "DiscretizationResult":
        """Unpack the final timestep of a raw discretization matrix ``V``."""
        i1, i2 = n_x, n_x + n_x * n_x
        i3, i4 = i2 + n_x * n_u, i2 + 2 * n_x * n_u
        V_final = V[:, -1].reshape(-1, i4)
        return cls(
            V=np.asarray(V),
            x_prop=V_final[:, :i1],
            A_d=V_final[:, i1:i2].reshape(N - 1, n_x, n_x),
            B_d=V_final[:, i2:i3].reshape(N - 1, n_x, n_u),
            C_d=V_final[:, i3:i4].reshape(N - 1, n_x, n_u),
        )

    @classmethod
    def from_VW(
        cls,
        V: np.ndarray,
        W: np.ndarray,
        n_x: int,
        n_u: int,
        N: int,
    ) -> "DiscretizationResult":
        """Unpack continuous and impulsive discretization blocks from ``V`` and ``W``."""
        base = cls.from_V(V=V, n_x=n_x, n_u=n_u, N=N)

        W_arr = np.asarray(W)
        i_w = n_x + n_x * n_x + n_x * n_u
        i1 = n_x
        i2 = i1 + n_x * n_x
        i3 = i2 + n_x * n_u

        W_final = W_arr[:, -1].reshape(-1, i_w)

        return cls(
            V=base.V,
            x_prop=base.x_prop,
            A_d=base.A_d,
            B_d=base.B_d,
            C_d=base.C_d,
            x_prop_plus=W_final[:, :i1],
            D_d=W_final[:, i1:i2].reshape(W_final.shape[0], n_x, n_x),
            E_d=W_final[:, i2:i3].reshape(W_final.shape[0], n_x, n_u),
        )
from_V(V: np.ndarray, n_x: int, n_u: int, N: int) -> DiscretizationResult classmethod

Unpack the final timestep of a raw discretization matrix V.

Source code in openscvx/algorithms/base.py
@classmethod
def from_V(
    cls,
    V: np.ndarray,
    n_x: int,
    n_u: int,
    N: int,
) -> "DiscretizationResult":
    """Unpack the final timestep of a raw discretization matrix ``V``."""
    i1, i2 = n_x, n_x + n_x * n_x
    i3, i4 = i2 + n_x * n_u, i2 + 2 * n_x * n_u
    V_final = V[:, -1].reshape(-1, i4)
    return cls(
        V=np.asarray(V),
        x_prop=V_final[:, :i1],
        A_d=V_final[:, i1:i2].reshape(N - 1, n_x, n_x),
        B_d=V_final[:, i2:i3].reshape(N - 1, n_x, n_u),
        C_d=V_final[:, i3:i4].reshape(N - 1, n_x, n_u),
    )
from_VW(V: np.ndarray, W: np.ndarray, n_x: int, n_u: int, N: int) -> DiscretizationResult classmethod

Unpack continuous and impulsive discretization blocks from V and W.

Source code in openscvx/algorithms/base.py
@classmethod
def from_VW(
    cls,
    V: np.ndarray,
    W: np.ndarray,
    n_x: int,
    n_u: int,
    N: int,
) -> "DiscretizationResult":
    """Unpack continuous and impulsive discretization blocks from ``V`` and ``W``."""
    base = cls.from_V(V=V, n_x=n_x, n_u=n_u, N=N)

    W_arr = np.asarray(W)
    i_w = n_x + n_x * n_x + n_x * n_u
    i1 = n_x
    i2 = i1 + n_x * n_x
    i3 = i2 + n_x * n_u

    W_final = W_arr[:, -1].reshape(-1, i_w)

    return cls(
        V=base.V,
        x_prop=base.x_prop,
        A_d=base.A_d,
        B_d=base.B_d,
        C_d=base.C_d,
        x_prop_plus=W_final[:, :i1],
        D_d=W_final[:, i1:i2].reshape(W_final.shape[0], n_x, n_x),
        E_d=W_final[:, i2:i3].reshape(W_final.shape[0], n_x, n_u),
    )