Skip to content

Shooting Methods

Shooting methods convert an optimal control problem into a finite-dimensional optimization problem by parameterizing the control and using ODE integration to compute the resulting trajectory.

Given a controlled ODE:

dydt=f(t,y,u),y(t0)=y0\frac{dy}{dt} = f(t, y, u), \quad y(t_0) = y_0

find the control u(t)u(t) that minimizes:

J=ϕ(y(T))+t0TL(t,y,u)dtJ = \phi(y(T)) + \int_{t_0}^{T} L(t, y, u) \, dt

where ϕ\phi is the terminal cost and LL is the running cost.

Single shooting discretizes the control into NN piecewise-constant segments and treats the segment values as decision variables:

u(t)=ukfort[tk,tk+1)u(t) = u_k \quad \text{for} \quad t \in [t_k, t_{k+1})

The trajectory is computed by forward integration, and the objective is minimized by a nonlinear optimizer.

use numra::ocp::{ShootingProblem, ShootingResult};
// Minimize terminal distance for a controlled oscillator
// dy1/dt = y2, dy2/dt = u (control is acceleration)
let result = ShootingProblem::<f64>::new(2, 1) // 2 states, 1 control
.dynamics(|t, y, dydt, u| {
dydt[0] = y[1];
dydt[1] = u[0];
})
.initial_state(&[0.0, 0.0])
.time_span(0.0, 2.0)
.n_segments(20)
.terminal_cost(|y_tf| {
// Minimize distance to target (1.0, 0.0)
let dx = y_tf[0] - 1.0;
let dv = y_tf[1];
dx * dx + dv * dv
})
.running_cost(|_t, _y, u| {
0.01 * u[0] * u[0] // penalize control effort
})
.control_bounds(0, (-5.0, 5.0)) // bound control magnitude
.solve()
.unwrap();
println!("Objective: {:.6e}", result.objective);
println!("Converged: {}", result.converged);
println!("Iterations: {}", result.iterations);
println!("Final state: [{:.4}, {:.4}]", result.final_state[0], result.final_state[1]);
  1. Parameterize: Represent u(t)u(t) as NN scalar values
  2. Forward integrate: Given a control vector, integrate the ODE from t0t_0 to TT
  3. Compute cost: Sum the terminal cost and discretized running cost
  4. Optimize: Use BFGS or L-BFGS to minimize the cost over control variables
  5. Iterate: Repeat until convergence

The result contains the full solution:

FieldTypeDescription
controlsVec<S>Optimal control values (flat)
final_stateVec<S>Terminal state y(T)y(T)
objectiveSOptimal cost
convergedboolOptimizer convergence
iterationsusizeNumber of optimizer iterations
t_trajectoryVec<S>Time grid
y_trajectoryVec<S>State trajectory (row-major)

Multiple shooting breaks the time horizon into intervals and treats both controls and intermediate states as decision variables. This improves conditioning for problems where single shooting suffers from sensitivity to control perturbations.

use numra::ocp::{MultipleShootingProblem, MultipleShootingResult};
let result = MultipleShootingProblem::<f64>::new(2, 1)
.dynamics(|t, y, dydt, u| {
dydt[0] = y[1];
dydt[1] = u[0] - y[0];
})
.initial_state(&[1.0, 0.0])
.time_span(0.0, 5.0)
.n_segments(10)
.terminal_cost(|y| y[0] * y[0] + y[1] * y[1])
.solve()
.unwrap();
AspectSingle ShootingMultiple Shooting
Decision variablesControls onlyControls + states
NLP sizenu×Nn_u \times N(nu+nx)×N(n_u + n_x) \times N
ConditioningPoor for long horizonsBetter conditioned
ConstraintsImplicit (via ODE)Explicit continuity
ImplementationSimplerMore complex
ParallelismSequentialIntervals independent
  • Start simple: Use single shooting first. Switch to multiple shooting if convergence is poor or the time horizon is long.
  • Control bounds: Always provide reasonable bounds. Unbounded controls can cause the optimizer to try extreme values that break the ODE integrator.
  • Number of segments: More segments = finer control resolution but larger NLP. Start with 10-20 and refine.
  • ODE tolerances: The shooting method is only as accurate as its ODE integration. Use tight tolerances (rtol=1e-8) for the inner integration.