Getting Started

Setting up and solving a problem with ALTRO is very straight-forward. Let's walk through an example of getting a Dubins car to drive through some circular obstacles.

1. Load the packages

Our first step is to load the required packages. Since we need to define our dynamics model, we need RobotDynamics.jl, and we need TrajectoryOptimization.jl to define our problem. We'll also import StaticArrays.jl for fast, allocation-free matrix methods, and the LinearAlgebra module. To avoid having to type TrajectoryOptimization and RobotDynamics all the time, we also create some convenient aliases.

using Altro
using TrajectoryOptimization
using RobotDynamics
using StaticArrays, LinearAlgebra
const TO = TrajectoryOptimization
const RD = RobotDynamics

2. Set up the dynamics model

We now define our dynamics model using RobotDynamics.jl. We define a new type Car that inherits from RobotDynamics.ContinuousDynamics. We can store any of our model parameters in this type. After defining the state and control dimensions and the continuous dynamics, we're done defining our model. Integration of the dynamics and the dynamics derivatives can be done automatically.

using ForwardDiff  # needed for @autodiff
using FiniteDiff   # needed for @autodiff

RD.@autodiff struct DubinsCar <: RD.ContinuousDynamics end
RD.state_dim(::DubinsCar) = 3
RD.control_dim(::DubinsCar) = 2

function RD.dynamics(::DubinsCar,x,u)
    ẋ = @SVector [u[1]*cos(x[3]),
                  u[1]*sin(x[3]),
                  u[2]]
end

The code above is the minimal amount of code we need to write to define our dynamics. We can also define in-place evaluation methods and an analytic Jacobian:

function RD.dynamics!(::DubinsCar, xdot, x, u)
    xdot[1] = u[1] * cos(x[3])
    xdot[2] = u[1] * sin(x[3])
    xdot[3] = u[2]
    return nothing
end

function RD.jacobian!(::DubinsCar, J, xdot, x, u)
    J .= 0
    J[1,3] = -u[1] * sin(x[3])
    J[1,4] = cos(x[3])
    J[2,3] = u[1] * cos(x[3])
    J[2,4] = sin(x[3])
    J[3,5] = 1.0
    return nothing
end

# Specify the default method to be used when calling the dynamics
#   options are `RD.StaticReturn()` or `RD.InPlace()`
RD.default_signature(::DubinsCar) = RD.StaticReturn()

# Specify the default method for evaluating the dynamics Jacobian
#   options are `RD.ForwardAD()`, `RD.FiniteDifference()`, or `RD.UserDefined()`
RD.default_diffmethod(::DubinsCar) = RD.UserDefined()
Tip

We use RobotDynamics.@autodiff to automatically define methods to evaluate the Jacobian of our dynamics function. See the RobotDynamics documentation for more details. Note that we have to include the FiniteDiff and ForwardDiff packages to use this method.

3. Set up the objective

Once we've defined the model, we can now start defining our problem. Let's start by defining the discretization:

model = DubinsCar()
dmodel = RD.DiscretizedDynamics{RD.RK4}(model)
n,m = size(model)    # get state and control dimension
N = 101              # number of time steps (knot points). Should be odd.
tf = 3.0             # total time (sec)
dt = tf / (N-1)      # time step (sec)

Note that we need a discrete version of our dynamics model, which we can obtain using the RobotDynamics.DiscretizedDynamics type. This type creates a RobotDynamics.DiscreteDynamics type from a RobotDynamics.ContinuousDynamics type using a supplied integrator. Here we use the 4th-order explicit Runge-Kutta method provided by RobotDynamics.jl.

Now we specify our initial and final conditions:

x0 = SA_F64[0,0,0]   # start at the origin
xf = SA_F64[1,2,pi]  # goal state

For our objective, let's define a quadratic cost function that penalizes distance from the goal state:

Q  = Diagonal(SA[0.1,0.1,0.01])
R  = Diagonal(SA[0.01, 0.1])
Qf = Diagonal(SA[1e2,1e2,1e3])
obj = LQRObjective(Q,R,Qf,xf,N)

4. Add the constraints

Now let's define the constraints for our problem. We're going to bound the workspace of the robot, and add two obstacles. We start by defining a ConstraintList, which is going to hold all of the constraints and make sure they're dimensions are consistent. Here we add a goal constraint at the last time step, a workspace constraint, and then the circular obstacle constraint.

cons = ConstraintList(n,m,N)

# Goal constraint
goal = GoalConstraint(xf)
add_constraint!(cons, goal, N)

# Workspace constraint
bnd = BoundConstraint(n,m, x_min=[-0.1,-0.1,-Inf], x_max=[5,5,Inf])
add_constraint!(cons, bnd, 1:N-1)

# Obstacle Constraint
#   Defines two circular obstacles:
#   - Position (1,1) with radius 0.2
#   - Position (2,1) with radius 0.3
obs = CircleConstraint(n, SA_F64[1,2], SA_F64[1,1], SA[0.2, 0.3])
add_constraint!(cons, bnd, 1:N-1)

5. Define the problem

With the dynamics model, discretization, objective, constraints, and initial condition defined, we're ready to define the problem, which we do with TrajectoryOptimization.Problem.

prob = Problem(model, obj, x0, tf, xf=xf, constraints=cons)

Initialization is key when nonlinear optimization problems with gradient-based methods. Since this problem is pretty easy, we'll just initialize it with small random noise on the controls and then simulate the system forward in time.

initial_controls!(prob, [@SVector rand(m) for k = 1:N-1])
rollout!(prob)   # simulate the system forward in time with the new controls

6. Intialize the solver

With the problem now defined, we're ready to start using Altro.jl (everything up to this point used only RobotDynamics.jl or TrajectoryOptimization.jl). All we need to do is create an ALTROSolver.

solver = ALTROSolver(prob)

Setting Solver Options

We can set solver options via keyword arguments to the constructor, or by passing in a SolverOptions type:

# Set up solver options
opts = SolverOptions()
opts.cost_tolerance = 1e-5

# Create a solver, adding in additional options
solver = ALTROSolver(prob, opts, show_summary=false)

You can also use the set_options! method on a solver once it's created.

7. Solve the problem

With the solver initialized, we can now solve the problem with a simple call to solve!:

solve!(solver)

8. Post-analysis

Checking solve status

Once the solve is complete, we can look at a few things. The first is to check if the solve is successful:

status(solver)
SOLVE_SUCCEEDED::TerminationStatus = 2

We can also extract some more information

println("Number of iterations: ", iterations(solver))
println("Final cost: ", cost(solver))
println("Final constraint satisfaction: ", max_violation(solver))
Number of iterations: 14
Final cost: 12.480778190320422
Final constraint satisfaction: 9.890390728628518e-10

Extracting the solution

We can extract the state and control trajectories, which are returned as vectors of SVectors:

X = states(solver)     # alternatively states(prob)
U = controls(solver)   # alternatively controls(prob)
100-element Vector{StaticArrays.SVector{2, Float64}}:
 [0.10408405854246962, 2.5451707742226737]
 [0.778957979796186, 2.518515157623271]
 [1.3567924327472547, 2.468825826779719]
 [1.8387934533788186, 2.402009965794402]
 [2.2294913044845535, 2.3233258727973634]
 [2.5357782454256257, 2.2372530805461754]
 [2.766006504540963, 2.1474551033458824]
 [2.929221470045575, 2.0568117115604876]
 [3.034562704519594, 1.9674958859884335]
 [3.0908333559401266, 1.8810737002456277]
 ⋮
 [0.10448420508238694, 0.7290808776634575]
 [0.11049065217186374, 0.728319543594336]
 [0.1173845209146125, 0.727610580514097]
 [0.12523466471075592, 0.7269520062455004]
 [0.13411701784645094, 0.7263417250476808]
 [0.1441151789490993, 0.7257775194027796]
 [0.15532105088439324, 0.7252570426685888]
 [0.16783554171240503, 0.7247778127553292]
 [0.18176933107679002, 0.7243372076679316]

If you prefer to work with matrices, you can convert them easily:

Xm = hcat(Vector.(X)...)  # convert to normal Vector before concatenating so it's fast
Um = hcat(Vector.(U)...)
2×100 Matrix{Float64}:
 0.104084  0.778958  1.35679  1.83879  …  0.155321  0.167836  0.181769
 2.54517   2.51852   2.46883  2.40201     0.725257  0.724778  0.724337
Tip

Converting a matrix into a vector of vectors is also very easy:

X = [col for col in eachcol(Xm)]

Or if you want static vectors:

X = [SVector{n}(col) for col in eachcol(Xm)]

Extracting the final feedback gains

Since ALTRO uses iLQR, the solver computes a locally optimal linear feedback policy which can be useful for tracking purposes. We can extract it from the internal Altro.iLQRSolver:

ilqr = Altro.get_ilqr(solver)
K = ilqr.K  # feedback gain matrices
d = ilqr.d  # feedforward gains. Should be small.
100-element Vector{SubArray{Float64, 1, Matrix{Float64}, Tuple{Base.Slice{Base.OneTo{Int64}}, Int64}, true}}:
 [1.668961238793008e-5, 5.492534848435653e-5]
 [1.8981371778099341e-6, 3.2707579515113506e-5]
 [-8.101039311678598e-6, 1.4065367230031992e-5]
 [-1.408805499880302e-5, -1.3813098779229467e-6]
 [-1.6843546962291917e-5, -1.4027529058692502e-5]
 [-1.707934237775111e-5, -2.4253193541925443e-5]
 [-1.5406491695447024e-5, -3.240719944145175e-5]
 [-1.2328868652201589e-5, -3.8800735859366714e-5]
 [-8.251135844210571e-6, -4.370604007808595e-5]
 [-3.492857987688107e-6, -4.735822679595479e-5]
 ⋮
 [-2.7568096984973166e-6, -1.5739135677024633e-6]
 [-3.599628847076479e-6, -1.387786162445439e-6]
 [-4.462829039317476e-6, -1.1913843233622962e-6]
 [-5.36194325882611e-6, -9.831297864504254e-7]
 [-6.260875359935043e-6, -7.624047766616518e-7]
 [-6.929724688971699e-6, -5.329416950531488e-7]
 [-6.600394113234778e-6, -3.1194892943202324e-7]
 [-3.9128865299800425e-6, -1.3499956668769506e-7]
 [8.553940657138559e-7, -1.3725787732019677e-9]

Additional solver stats

We can extract more detailed information on the solve from SolverStats

Altro.stats(solver)

The most relevant fields are the cost, c_max, and gradient. These give the history of these values for each iteration. The iteration_outer can also be helpful to know which iterations were outer loop (augmented Lagrangian) iterations. The tsolve field gives the total solve time in milliseconds.