Article

Radau Pseudospectral Collocation in CasADi: Trajectory Optimization

Tutorial of Legendre–Gauss–Radau pseudospectral collocation in Python/CasADi for trajectory optimization.

Author: David Timothy

Motivation

Many widely used optimal control tools use pseudospectral collocation as the transcription method for trajectory optimization. For example, GPOPS-II (Patterson, M. A., & Rao, A. V. (2014)), PSOPT (Becerra, V. M. (2010)), and OpenMDAO Dymos (Falck, R., et al. (2021)).

This article shows how to implement Legendre–Gauss–Radau (LGR) pseudospectral collocation in CasADi (Andersson, J. A. E., Gillis, J., Horn, G., et al. (2019)), focusing on the p-method (single interval, raise polynomial degree). The goal is to make the mapping between theory and code explicit. We’ll use a small double-integrator example to set the foundation.

From here, readers can explore hp-adaptive (Darby, C. L., Hager, W. W., & Rao, A. V. (2011)) refinements and other adaptive strategies on top of this tutorial.


Problem Setup

Imagine a simple scenario of driving a car in a straight line. We can only control the acceleration and deceleration, and we have a limit on how hard we can push. Define the state and control

X(t)=[p(t)v(t)],U(t)RX(t)=\begin{bmatrix} p(t) \\ v(t) \end{bmatrix},\quad U(t)\in\mathbb{R}(1)

where p(t)p(t) is position [m][m], v(t)v(t) is velocity [m/s][m/s], and U(t)U(t) is acceleration [m/s2][m/s^2]. The state-space dynamics are

X˙(t):=f(X(t),U(t))=[v(t)U(t)],U(t)Amax\dot X(t) := f\big(X(t),U(t)\big) = \begin{bmatrix} v(t) \\ U(t) \end{bmatrix},\qquad |U(t)| \le A_{\max}(2)

The car starts at rest and should arrive at LL meters, also at rest. We want it fast but without wasting effort, so we use a Bolza objective (time + quadratic control):

J=tf+0tfU(t)2dtJ = t_f + \int_{0}^{t_f} U(t)^2\,dt(3)

Putting it together as an optimal control problem:

minX(),U(),tf>0J=tf+0tfU(t)2dts.t.X˙(t)=f(X(t),U(t)),t[0,tf],U(t)Amax,X(0)=[00],X(tf)=[L0]\begin{aligned} \min_{X(\cdot),\,U(\cdot),\,t_f>0}\quad & J = t_f + \int_0^{t_f} U(t)^2\,dt \\[4pt] \text{s.t.}\quad & \dot X(t) = f\big(X(t),U(t)\big), \quad t\in[0,t_f], \\ & |U(t)| \le A_{\max}, \\ & X(0)=\begin{bmatrix}0 \\ 0\end{bmatrix},\quad X(t_f)=\begin{bmatrix}L \\ 0\end{bmatrix} \end{aligned}(4)
import numpy as np
import casadi as ca
from scipy.special import roots_jacobi

A_max = 2.5
L     = 100.0
N     = 20

Time mapping

We need a fixed grid to use LGR. LGR nodes live on [1,1][-1,1]. Our problem lives on [t0,tf][t_0,t_f]. So we map time with a simple affine function that sends τ=1\tau=-1 to t0t_0 and τ=+1\tau=+1 to tft_f:

t(τ)=tft02τ+tf+t02,dtdτ=tft02phaset(\tau)=\frac{t_f-t_0}{2}\,\tau+\frac{t_f+t_0}{2},\qquad \frac{dt}{d\tau}=\frac{t_f-t_0}{2}\coloneqq\text{phase}(5)

Now we convert both the dynamics and objective. We view X(τ)=X(t(τ))X(\tau)=X(t(\tau)). By the chain rule,

dXdτ=dtdτdXdt=tft02f(X(τ),U(τ))\frac{dX}{d\tau}=\frac{dt}{d\tau}\,\frac{dX}{dt} =\frac{t_f-t_0}{2}\,f\big(X(\tau),U(\tau)\big)(6)

The control bound is pointwise and unchanged: U(τ)Amax|U(\tau)|\le A_{\max}. Endpoints move to the ends of [1,1][-1,1]:

X(1)=X(t0)=[00],X(+1)=X(tf)=[L0]X(-1)=X(t_0)=\begin{bmatrix}0 \\ 0\end{bmatrix},\qquad X(+1)=X(t_f)=\begin{bmatrix}L \\ 0\end{bmatrix}(7)

For the objective, we start with the general form,

J=(tft0)+t0tfU(t)2dtJ=(t_f-t_0)+\int_{t_0}^{t_f}U(t)^2\,dt(8)

Change variables using t=t(τ)t=t(\tau):

t0tfU(t)2dt=11U(τ)2dtdτdτ=tft0211U(τ)2dτ\int_{t_0}^{t_f}U(t)^2\,dt =\int_{-1}^{1}U(\tau)^2\,\frac{dt}{d\tau}\,d\tau =\frac{t_f-t_0}{2}\int_{-1}^{1}U(\tau)^2\,d\tau(9)

so

J=(tft0)+tft0211U(τ)2dτJ=(t_f-t_0)+\frac{t_f-t_0}{2}\int_{-1}^{1}U(\tau)^2\,d\tau(10)

For our case we start at t0=0t_0=0. Plugging in,

t(τ)=tf2(τ+1)dtdτ=tf2dXdτ=tf2f(X,U)J=tf+tf211U(τ)2dτ\begin{aligned} t(\tau) &= \frac{t_f}{2}(\tau+1)\\ \frac{dt}{d\tau} &= \frac{t_f}{2}\\ \frac{dX}{d\tau} &= \frac{t_f}{2}\,f(X,U)\\ J &= t_f+\frac{t_f}{2}\int_{-1}^{1}U(\tau)^2\,d\tau \end{aligned}(11)
opti  = ca.Opti()
tf    = opti.variable()
phase = 0.5 * tf

LGR nodes and weights

Our dynamics are continuous in time. To make the problem computable, we discretize by picking a finite set of times (collocation points), enforce the physics only at those points, and interpolate between them with a Lagrange polynomial built on our chosen nodes. We’ll use Left Legendre–Gauss–Radau (LGR) points on τ[1,1]\tau\in[-1,1]. “Left” means we include the left endpoint 1-1 as a collocation node (good because the initial state is known), and fill the interior with special nodes from orthogonal polynomial theory.

The Gauss–Radau collocation nodes satisfy the Legendre relation

PN1(xk)+PN(xk)1+xk=0\frac{P_{N-1}(x_k)+P_N(x_k)}{1+x_k}=0(12)

with 1-1 included as a node. In practice (following Hale, N., & Townsend, A. (2013)), we construct these nodes by taking 1-1 and attaching it to the roots of the Jacobi polynomial PN1(0,1)(x)P_{N-1}^{(0,1)}(x). Those roots give exactly the interior Radau nodes. Conveniently, SciPy exposes these roots and their Gauss–Jacobi weights via roots_jacobi(N-1, 0, 1). The internal algorithm is out of scope of this article, but readers are encouraged to check the SciPy's official documentation about roots_jacobi.

Why do we also need the weights? Because we’ll approximate integrals (for the objective) by a weighted sum on [1,1][-1,1]. For Left LGR the Radau weights are:

w(1)=2N2,wk=1(1xk)[PN1(xk)]2for interior xk(1,1)w(-1)=\frac{2}{N^2},\qquad \\ w_k=\frac{1}{(1-x_k)\,[P_{N-1}'(x_k)]^2}\quad\text{for interior }x_k\in(-1,1)(13)

and they satisfy iwi=2\sum_i w_i=2. Equivalently, take the Gauss–Jacobi weights wkGJw_k^{\mathrm{GJ}} returned by SciPy for (α,β)=(0,1)(\alpha,\beta)=(0,1) (these integrate 1+x1+x on [1,1][-1,1]), and convert interior weights by

wk=wkGJ1+xk,w(1)=2N2w_k=\frac{w_k^{\mathrm{GJ}}}{1+x_k},\qquad w(-1)=\frac{2}{N^2}(14)

We’ll use these wiw_i inside the cost as

J=tf+tf2i=1NwiUi2J = t_f + \frac{t_f}{2}\sum_{i=1}^{N} w_i\,U_i^2(15)

Left-LGR collocation nodes on a straight grid Left LGR collocation nodes distributed across [1,1][-1,1].

def lgr_left_nodes_weights(N):
    if N == 1:
        colloc = np.array([-1.0], dtype=float)
        w_lgr  = np.array([2.0], dtype=float)
    else:
        x, w_gj = roots_jacobi(N-1, 0.0, 1.0)
        x   = np.sort(np.asarray(x, float))
        wgj = np.asarray(w_gj, float)
        colloc = np.concatenate(([-1.0], x))
        w_int  = wgj / (1.0 + x)
        w_lgr  = np.concatenate(([2.0/(N**2)], w_int))
    state = np.concatenate((colloc, [1.0]))
    return colloc, w_lgr, state

Lagrange interpolation and Barycentric

We store the state on the state grid τs={τs1,,τs,N+1}\tau_s=\{\tau_{s1},\dots,\tau_{s,N+1}\}. The classic Lagrange basis is

j(τ)=m=1mjN+1ττsmτsjτsm,j(τsk)=δjk\ell_j(\tau)=\prod_{\substack{m=1 \\ m\ne j}}^{N+1}\frac{\tau-\tau_{s m}}{\tau_{s j}-\tau_{s m}},\qquad \ell_j(\tau_{s k})=\delta_{jk}(16)

Given nodal values Xj=X(τsj)X_j=X(\tau_{s j}), the interpolant is

X(τ)=j=1N+1j(τ)Xj(Lagrange)\begin{aligned} X(\tau)=\sum_{j=1}^{N+1}\ell_j(\tau)\,X_j \end{aligned}\tag{Lagrange}(17)

Directly evaluating Lagrange is numerically unstable for moderate NN, it builds big products, suffers cancellation near nodes, and is slower than it needs to be. The barycentric form evaluates the same polynomial but in a stable way. This isn’t an approximation. Berrut & Trefethen showed that the barycentric and Lagrange forms are mathematically identical. Barycentric is just the robust way to compute it.

We define the first-form barycentric weights as

wj=1m=1mjN+1(τsjτsm),j=1,,N+1w_j=\frac{1}{\displaystyle\prod_{\substack{m=1 \\ m\ne j}}^{N+1}(\tau_{s j}-\tau_{s m})},\qquad j=1,\dots,N+1(18)

With these, the barycentric interpolant is

X(τ)=j=1N+1wjXjττsjj=1N+1wjττsj,(Barycentric)\begin{aligned} X(\tau)= \frac{\displaystyle\sum_{j=1}^{N+1}\frac{w_j\,X_j}{\tau-\tau_{s j}}} {\displaystyle\sum_{j=1}^{N+1}\frac{w_j}{\tau-\tau_{s j}}},\qquad \end{aligned}\tag{Barycentric}(19)
_NUM_EPS = 1e-14

def barycentric_weights(nodes):
    x = np.asarray(nodes, float)
    M = len(x)
    w = np.ones(M, float)
    for j in range(M):
        prod = 1.0
        xj = x[j]
        for m in range(M):
            if m == j:
                continue
            d = xj - x[m]
            if abs(d) < _NUM_EPS:
                d = _NUM_EPS if d == 0 else np.sign(d) * _NUM_EPS
            prod *= d
        w[j] = 1.0 / prod
    return w

Dynamics matching at the collocation nodes

Our model is in state space: X˙=f(X,U)\dot X = f(X,U). To enforce this at the collocation nodes, we first need X˙\dot X from our polynomial interpolant.

First, differentiate the interpolant. We wrote the state on the state grid τs\tau_s as

X(τ)=j=1N+1j(τ)XjX(\tau)=\sum_{j=1}^{N+1}\ell_j(\tau)\,X_j(20)

so its derivative is

dXdτ(τ)=j=1N+1j(τ)Xj\frac{dX}{d\tau}(\tau)=\sum_{j=1}^{N+1}\ell'_j(\tau)\,X_j(21)

We evaluate that derivative at each collocation node. Left LGR includes the collocation nodes in the state grid, so for a collocation node ξ=τci=τsi\xi=\tau_{c i}=\tau_{s i} we can use the included-node barycentric identity (with first-form barycentric weights wjw_j on τs\tau_s):

j(ξ)={wjwi(ξτsj),ji,miwmwi(ξτsm),j=i\ell'_j(\xi)= \begin{cases} \dfrac{w_j}{w_i\,(\xi-\tau_{s j})}, & j\ne i, \\[6pt] -\displaystyle\sum_{m\ne i}\dfrac{w_m}{w_i\,(\xi-\tau_{s m})}, & j=i \end{cases}(22)

Stacking these coefficients for all jj gives the ii-th row of a rectangular matrix DRN×(N+1)D\in\mathbb{R}^{N\times(N+1)}, with

dXdττ=τci=(DX)i\left.\frac{dX}{d\tau}\right|_{\tau=\tau_{c i}} = (D\,\mathbf{X})_i(23)

To quickly check your implementation, each row of DD should sums to zero (derivative of a constant).

Finally, match the dynamics via the time map. From the time mapping, dtdτ=tf2\tfrac{dt}{d\tau}=\tfrac{t_f}{2}, so

dXdτ=tf2f(X,U)\frac{dX}{d\tau}=\frac{t_f}{2}\,f(X,U)(24)

Enforcing this at the collocation nodes gives the dynamics-matching equations:

(DX)i=tf2f(Xi,Ui),i=1,,N(D\,\mathbf{X})_i = \frac{t_f}{2}\,f\big(X_i,\,U_i\big),\qquad i=1,\dots,N(25)

We need X˙\dot X to enforce X˙=f(X,U)\dot X=f(X,U). We get X˙\dot X by differentiating the interpolant then we evaluate it at the collocation nodes using the included-node barycentric formula, and we scale it by tf/2t_f/2 from the time map (Berrut, J.-P., & Trefethen, L. N. (2004)).

def D_rect(colloc_nodes, state_nodes, w_state):
    C = len(colloc_nodes)
    M = len(state_nodes)
    x = np.asarray(state_nodes, float)
    w = np.asarray(w_state, float)
    D = np.zeros((C, M), float)
    for i in range(C):
        xi = float(colloc_nodes[i])
        jstar = int(np.argmin(np.abs(x - xi)))
        wi = w[jstar]
        s = 0.0
        for j in range(M):
            if j == jstar:
                continue
            d = xi - x[j]
            if abs(d) < _NUM_EPS:
                d = _NUM_EPS if d == 0 else np.sign(d) * _NUM_EPS
            Dij = w[j] / (wi * d)
            D[i, j] = Dij
            s += Dij
        D[i, jstar] = -s
    return D

Discrete OCP on the LGR grid (ready for the solver)

We now write everything on the fixed grid. The decision variables are tft_f, the state samples XR2×(N+1)X\in\mathbb{R}^{2\times(N+1)} (rows p,vp, v) at the state grid, and the control samples UR1×NU\in\mathbb{R}^{1\times N} at the collocation nodes. Using the time map dtdτ=tf2\tfrac{dt}{d\tau}=\tfrac{t_f}{2} and the derivative matrix DRN×(N+1)D\in\mathbb{R}^{N\times(N+1)}, form

XτXDR2×N,F[v1:NU]R2×NX_\tau \coloneqq X D^\top \in \mathbb{R}^{2\times N},\qquad F \coloneqq \begin{bmatrix} v_{1:N} \\ U \end{bmatrix} \in \mathbb{R}^{2\times N}(26)

Dynamics matching at the nodes:

Xτ=tf2FX_\tau = \frac{t_f}{2}\,F(27)

Bounds and endpoints:

UiAmax,X(:,0)=[00],X(:,N)=[L0],tf>0|U_i|\le A_{\max},\quad X(:,0)=\begin{bmatrix}0 \\ 0\end{bmatrix},\quad X(:,N)=\begin{bmatrix}L \\ 0\end{bmatrix},\quad t_f>0(28)

Objective on the LGR grid:

J=tf+tf2i=1NwiUi2J = t_f + \frac{t_f}{2}\sum_{i=1}^{N} w_i\,U_i^2(29)

By construction, DRN×(N+1)D\in\mathbb{R}^{N\times(N+1)} has rows indexed by collocation nodes (i=1,,Ni=1,\dots,N) and columns indexed by state nodes (j=1,,N+1j=1,\dots,N+1), with entries Dij=j(τci)D_{i j}=\ell'_j(\tau_{c i}). We store state samples as columns of XR2×(N+1)X\in\mathbb{R}^{2\times(N+1)}: X:,j=XjX_{:,j}=X_j. The derivative of the interpolant at collocation node ii is

dXdττ=τci=j=1N+1j(τci)Xj=j=1N+1DijX:,j\left.\frac{dX}{d\tau}\right|_{\tau=\tau_{c i}} =\sum_{j=1}^{N+1} \ell'_j(\tau_{c i})\,X_j =\sum_{j=1}^{N+1} D_{i j}\,X_{:,j}(30)

Multiplying on the right by DD^\top makes each column of XDX D^\top produce exactly that sum:

(XD):,i=j=1N+1X:,j(D)j,i=j=1N+1X:,jDij(X D^\top)_{:,i} =\sum_{j=1}^{N+1} X_{:,j}\,(D^\top)_{j,i} =\sum_{j=1}^{N+1} X_{:,j}\,D_{i j}(31)

Shapes align naturally: XX is 2×(N+1)2\times(N{+}1), DD^\top is (N+1)×N(N{+}1)\times N, so XDX D^\top is 2×N2\times N, one column per collocation node. That’s why we use the transpose.

# variables
U  = opti.variable(1, N)        # control at collocation nodes
X  = opti.variable(2, N+1)      # states at state-grid nodes (rows: p, v)

# dynamics matching: X D^T = (tf/2) * [v; U]
Xtau = ca.mtimes(X, ca.DM(D_tau).T)   # 2 x N
F    = ca.vertcat(X[1, 0:N], U)       # 2 x N
opti.subject_to(Xtau == phase * F)

# bounds and endpoints
opti.subject_to(U <=  A_max)
opti.subject_to(U >= -A_max)
opti.subject_to(X[0, 0] == 0)
opti.subject_to(X[1, 0] == 0)
opti.subject_to(X[0, -1] == L)
opti.subject_to(X[1, -1] == 0)
opti.subject_to(tf > 0)

# objective: J = tf + (tf/2) * sum w_i U_i^2
J = tf + phase * ca.mtimes(ca.power(U, 2), ca.DM(w_lgr))
opti.minimize(J)

Assemble the NLP in CasADi

Now we put the pieces together. The decision variables are:

  • the final time tft_f,
  • the control samples UiU_i at the NN collocation nodes,
  • the state samples Xj=[p;v]jX_j=[p;v]_j at the N+1N{+}1 state-grid nodes.

We already have:

  • Left–LGR nodes τc\tau_c and weights wiw_i on [1,1][-1,1],
  • the state grid τs=τc{+1}\tau_s=\tau_c\cup\{+1\},
  • the barycentric weights on τs\tau_s,
  • the rectangular derivative matrix DRN×(N+1)D\in\mathbb{R}^{N\times(N+1)},
  • the time map scale dtdτ=tf2\tfrac{dt}{d\tau}=\tfrac{t_f}{2}.

We enforce the dynamics by matching the interpolant derivative to the scaled model at the collocation nodes:

(XD):,i=tf2[viUi],a([p;v],U)=[vU](X D^\top)_{:,i} = \frac{t_f}{2}\begin{bmatrix} v_i \\ U_i \end{bmatrix},\qquad a([p;v],U)=\begin{bmatrix} v \\ U \end{bmatrix}(32)

Add the pointwise control bound UiAmax|U_i|\le A_{\max}, the endpoints X(1)=[0;0]X(-1)=[0;0]^\top, X(+1)=[L;0]X(+1)=[L;0]^\top, the positivity tf>0t_f>0, and the cost

J=tf+tf2i=1NwiUi2J = t_f + \frac{t_f}{2}\sum_{i=1}^{N} w_i\,U_i^2(33)
# initial guesses
opti.set_initial(tf, 20) 
opti.set_initial(X, 0)
opti.set_initial(U, 0)

opti.solver("ipopt")
sol = opti.solve()

Full assembled code

import numpy as np
import casadi as ca
from scipy.special import roots_jacobi

A_max = 2.5
L     = 100.0
N     = 20

# Left-LGR nodes and weights
def lgr_left_nodes_weights(N):
    if N == 1:
        colloc = np.array([-1.0], dtype=float)
        w_lgr  = np.array([2.0], dtype=float)
    else:
        x, w_gj = roots_jacobi(N-1, 0.0, 1.0)  # zeros/weights of P_{N-1}^{(0,1)}
        x   = np.sort(np.asarray(x, float))
        wgj = np.asarray(w_gj, float)
        colloc = np.concatenate(([-1.0], x))
        w_int  = wgj / (1.0 + x)               # convert to Left Radau (unit weight)
        w_lgr  = np.concatenate(([2.0/(N**2)], w_int))
    state = np.concatenate((colloc, [1.0]))
    return colloc, w_lgr, state

# Barycentric weights
_NUM_EPS = 1e-14
def barycentric_weights(nodes):
    x = np.asarray(nodes, float)
    M = len(x)
    w = np.ones(M, float)
    for j in range(M):
        prod = 1.0
        xj = x[j]
        for m in range(M):
            if m == j:
                continue
            d = xj - x[m]
            if abs(d) < _NUM_EPS:
                d = _NUM_EPS if d == 0 else np.sign(d) * _NUM_EPS
            prod *= d
        w[j] = 1.0 / prod
    return w

# Rectangular derivative matrix D
def D_rect(colloc_nodes, state_nodes, w_state):
    C = len(colloc_nodes)
    M = len(state_nodes)
    x = np.asarray(state_nodes, float)
    w = np.asarray(w_state, float)
    D = np.zeros((C, M), float)
    for i in range(C):
        xi = float(colloc_nodes[i])
        jstar = int(np.argmin(np.abs(x - xi)))
        wi = w[jstar]
        s = 0.0
        for j in range(M):
            if j == jstar:
                continue
            d = xi - x[j]
            if abs(d) < _NUM_EPS:
                d = _NUM_EPS if d == 0 else np.sign(d) * _NUM_EPS
            Dij = w[j] / (wi * d)
            D[i, j] = Dij
            s += Dij
        D[i, jstar] = -s
    return D

# Build nodes/weights and D on tau in [-1,1]
tau_c, w_lgr, tau_s = lgr_left_nodes_weights(N)
bary_w = barycentric_weights(tau_s)
D_tau  = D_rect(tau_c, tau_s, bary_w)   # shape: N x (N+1)

# CasADi NLP assembly and solve
opti  = ca.Opti()
tf    = opti.variable()                  # final time
U     = opti.variable(1, N)              # control at collocation nodes
X     = opti.variable(2, N+1)            # states at state nodes (rows: p, v)
phase = 0.5 * tf

# Dynamics matching at collocation nodes: (X D^T) = (tf/2) * [v; U]
Xtau = ca.mtimes(X, ca.DM(D_tau).T)      # 2 x N
F    = ca.vertcat(X[1, 0:N], U)          # 2 x N
opti.subject_to(Xtau == phase * F)

# Path bounds and endpoints
opti.subject_to(U <=  A_max)
opti.subject_to(U >= -A_max)
opti.subject_to(X[0, 0] == 0)
opti.subject_to(X[1, 0] == 0)
opti.subject_to(X[0, -1] == L)
opti.subject_to(X[1, -1] == 0)
opti.subject_to(tf > 0)

# Objective: J = tf + (tf/2) * sum_i w_i * U_i^2
J = tf + phase * ca.mtimes(ca.power(U, 2), ca.DM(w_lgr))
opti.minimize(J)

# Neutral initial guesses
opti.set_initial(tf, 20)
opti.set_initial(X, 0)
opti.set_initial(U, 0)

# Solve
opti.solver("ipopt")
sol = opti.solve()

# Report
_tf = float(sol.value(tf))
_X  = np.array(sol.value(X))
_U  = np.array(sol.value(U)).ravel()
quad_u = float(np.dot(_U**2, w_lgr))
J_val  = _tf + 0.5*_tf*quad_u

print(f"tf (s): {_tf:.9f}")
print(f"J = tf + (tf/2) sum w_i U_i^2 = {J_val:.9f}")
print(f"p(0), v(0) = ({_X[0,0]:.6f}, {_X[1,0]:.6f})   p(tf), v(tf) = ({_X[0,-1]:.6f}, {_X[1,-1]:.6f})")
print("max |U|:", float(np.max(np.abs(_U))))
print("sum w (should be 2):", float(np.sum(w_lgr)))
print("max row-sum |D| (should be ~0):", float(np.max(np.abs(D_tau.sum(axis=1)))))

Optimal position, velocity, and control trajectories from the assembled solver Position, velocity, and control trajectories produced by the full CasADi transcription.


Comparison with the analytical solution

Now we compare to the closed-form solution available for the pure time-optimal problem. To the best of my knowledge, no general closed-form solution exists for objectives that combine final time and control effort. Therefore, let's consider the pure minimum time optimization problem where we only minimize the final time without minimizing the control effort.

J = tf
opti.minimize(J)

We adapt the 1D bang–bang relations from (LaValle, A. J., Sakcak, B., & LaValle, S. M. (2023)) to this article's notation (p,v,U)(p,v,U).

v(0)t1+12U1t12+(v(0)+U1t1)t2+12U2t22=p(tf)p(0),v(0)\,t_1+\tfrac12 U_1 t_1^2+\big(v(0)+U_1 t_1\big)t_2+\tfrac12 U_2 t_2^2 = p(t_f)-p(0),(34)
U1t1+U2t2=v(tf)v(0).U_1 t_1 + U_2 t_2 = v(t_f) - v(0).(35)

We then set

p(tf)p(0)=L,v(0)=v(tf)=0,U1=+Amax,U2=Amax.p(t_f)-p(0)=L,\quad v(0)=v(t_f)=0,\quad U_1=+A_{\max},\quad U_2=-A_{\max}.(36)

From (35):

Amaxt1Amaxt2=0    t1=t2.A_{\max} t_1 - A_{\max} t_2 = 0 \;\Rightarrow\; t_1=t_2.(37)

Insert (37) into (34) with (36):

Amaxt12=L.A_{\max}\, t_1^2 = L.(38)

Hence

t1=t2=LAmax.t_1=t_2=\sqrt{\frac{L}{A_{\max}}}.(39)

And

tf=t1+t2=2LAmax.t_f^\star=t_1+t_2=2\sqrt{\frac{L}{A_{\max}}}.(40)

Assume Amax=2.5A_{\max}=2.5, L=100L=100, so the analytic reference is tf=2L/Amax=12.649110641st_f^\star = 2\sqrt{L/A_{\max}} = 12.649110641\,\text{s}.

Ntft_f [s]tftf\lvert t_f - t_f^\star\rvert [s]rel. err [%]
1012.71741060.06830000.5399589
2012.66572450.01661390.1313443
3012.65643670.00732600.0579173
4012.65321630.00410570.0324581
5012.65173260.00262200.0207288

References

Patterson, M. A., & Rao, A. V. (2014). GPOPS-II: A MATLAB software for solving multiple-phase optimal control problems using hp-adaptive Gaussian quadrature collocation methods and sparse nonlinear programming. ACM Transactions on Mathematical Software, 41(1), Article 1. https://doi.org/10.1145/2558904

Becerra, V. M. (2010). Solving complex optimal control problems at no cost with PSOPT. In 2010 IEEE International Symposium on Computer-Aided Control System Design (CACSD) (pp. 1391–1396). IEEE. https://doi.org/10.1109/CACSD.2010.5612676

Falck, R., et al. (2021). Dymos: A Python package for optimal control of multidisciplinary systems. Journal of Open Source Software, 6(59), 2809. https://doi.org/10.21105/joss.02809

Andersson, J. A. E., Gillis, J., Horn, G., et al. (2019). CasADi: A software framework for nonlinear optimization and optimal control. Mathematical Programming Computation, 11, 1–36. https://doi.org/10.1007/s12532-018-0139-4

Darby, C. L., Hager, W. W., & Rao, A. V. (2011). An hp-adaptive pseudospectral method for solving optimal control problems. Optimal Control Applications and Methods, 32, 476–502. https://doi.org/10.1002/oca.957

Virtanen, P., Gommers, R., Oliphant, T. E., Haberland, M., Reddy, T., Cournapeau, D., Burovski, E., Peterson, P., Weckesser, W., Bright, J., van der Walt, S. J., Brett, M., Wilson, J., Millman, K. J., Mayorov, N., Nelson, A. R. J., Jones, E., Kern, R., Larson, E., … SciPy 1.0 Contributors. (2020). SciPy 1.0: Fundamental algorithms for scientific computing in Python. Nature Methods, 17(3), 261–272. https://doi.org/10.1038/s41592-019-0686-2

Berrut, J.-P., & Trefethen, L. N. (2004). Barycentric Lagrange interpolation. SIAM Review, 46(3), 501–517. https://doi.org/10.1137/S0036144502417715

Hale, N., & Townsend, A. (2013). Fast and accurate computation of Gauss–Legendre and Gauss–Jacobi quadrature nodes and weights. SIAM Journal on Scientific Computing, 35(2), A652–A674. https://doi.org/10.1137/120889873

LaValle, A. J., Sakcak, B., & LaValle, S. M. (2023). Bang-bang boosting of RRTs. In 2023 IEEE/RSJ International Conference on Intelligent Robots and Systems (IROS) (pp. 2869–2876). IEEE. https://doi.org/10.1109/IROS55552.2023.10341760