MPC creates feedback by solving a new trajectory problem from each measured state. Can the decisions for an entire family of states be computed in advance instead? Dynamic programming decomposes a finite-horizon control problem into cost-to-go functions indexed by time and state.
The deterministic discrete-time optimal control problem provides the starting point. Stochastic successors will enter in the next chapter through conditional expectations.
Consider a typical DOCP of Bolza type:
Rather than considering only the total cost from the initial time to the final time, dynamic programming introduces the concept of cost from an arbitrary point in time to the end. This leads to the definition of the “cost-to-go” or “value function” :
This function represents the total cost incurred from stage onwards to the end of the time horizon, given that the system is initialized in state at stage . Suppose the problem has been solved from stage to the end, yielding the optimal cost-to-go for any state at stage . The question then becomes: how does this information inform the decision at stage ?
Given knowledge of the optimal behavior from onwards, the task reduces to determining the optimal action at stage . This control should minimize the sum of the immediate cost and the optimal future cost , where is the resulting state after applying action . Mathematically, this is expressed as:
This equation is known as Bellman’s equation, named after Richard Bellman, who formulated the principle of optimality:
An optimal policy has the property that whatever the previous state and decision, the remaining decisions must constitute an optimal policy with regard to the state resulting from the previous decision.
In other words, any sub-path of an optimal path, from any intermediate point to the end, must itself be optimal. This principle is the basis for the backward induction procedure which computes the optimal value function and provides closed-loop control capabilities without having to use an explicit NLP solver.
Dynamic programming can handle nonlinear systems and non-quadratic cost functions naturally. It provides a global optimal solution, when one exists, and can incorporate state and control constraints with relative ease. However, as the dimension of the state space increases, this approach suffers from what Bellman termed the “curse of dimensionality.” The computational complexity and memory requirements grow exponentially with the state dimension, rendering direct application of dynamic programming intractable for high-dimensional problems.
Fortunately, learning-based methods offer efficient tools to combat the curse of dimensionality on two fronts: by using function approximation (e.g., neural networks) to avoid explicit discretization, and by leveraging randomization through Monte Carlo methods inherent in the learning paradigm. Most of this course is dedicated to those ideas.
Backward Recursion¶
How does the terminal cost propagate backward into an optimal action and value for every earlier state?
The principle of optimality provides a methodology for solving optimal control problems. Beginning at the final time horizon and working backwards, at each stage the local optimization problem given by Bellman’s equation is solved. This process, termed backward recursion or backward induction, constructs the optimal value function stage by stage.
Upon completion of this backward pass, we now have access to the optimal control to take at any stage and in any state. Furthermore, we can simulate optimal trajectories from any initial state and applying the optimal policy at each stage to generate the optimal trajectory.
Example: Optimal Harvest in Resource Management¶
Dynamic programming is often used in resource management and conservation biology to devise policies to be implemented by decision makers and stakeholders : for eg. in fishereries, or timber harvesting. Per Conroy & Peterson (2013), we consider a population of a particular species, whose abundance we denote by , where represents discrete time steps. Our objective is to maximize the cumulative harvest over a finite time horizon, while also considering the long-term sustainability of the population. This optimization problem can be formulated as:
Here, represents the immediate reward function associated with harvesting, is the harvest rate at time , and denotes a terminal value function that could potentially assign value to the final population state. In this particular problem, we assign no terminal value to the final population state, setting and allowing us to focus solely on the cumulative harvest over the time horizon.
In our model population model, the abundance of a specicy ranges from 1 to 100 individuals. The decision variable is the harvest rate , which can take values from the set . The population dynamics are governed by a modified logistic growth model:
where the 0.3 represents the growth rate and 125 is the carrying capacity (the maximum population size given the available resources). The logistic growth model returns continuous values; however our DP formulation uses a discrete state space. Therefore, we also round the the outcomes to the nearest integer.
Applying the principle of optimality, we can express the optimal value function recursively:
with the boundary condition .
It’s worth noting that while this example uses a relatively simple model, the same principles can be applied to more complex scenarios involving stochasticity, multiple species interactions, or spatial heterogeneity.
Source
# label: dp-harvest-policy
# caption: Dynamic programming harvest example: printed output shows the optimal policy table, resulting population trajectory, and per-period harvests for an initial population of 50 fish.
%config InlineBackend.figure_format = 'retina'
import numpy as np
# Parameters
r_max = 0.3
K = 125
T = 20 # Number of time steps
N_max = 100 # Maximum population size to consider
h_max = 0.5 # Maximum harvest rate
h_step = 0.1 # Step size for harvest rate
# Create state and decision spaces
N_space = np.arange(1, N_max + 1)
h_space = np.arange(0, h_max + h_step, h_step)
# Initialize value function and policy
V = np.zeros((T + 1, len(N_space)))
policy = np.zeros((T, len(N_space)))
# Terminal value function (F_T)
def terminal_value(N):
return 0
# State return function (F)
def state_return(N, h):
return N * h
# State dynamics function
def state_dynamics(N, h):
return N + r_max * N * (1 - N / K) - N * h
# Backward iteration
for t in range(T - 1, -1, -1):
for i, N in enumerate(N_space):
max_value = float('-inf')
best_h = 0
for h in h_space:
if h > 1: # Ensure harvest rate doesn't exceed 100%
continue
next_N = state_dynamics(N, h)
if next_N < 1: # Ensure population doesn't go extinct
continue
next_N_index = np.searchsorted(N_space, next_N)
if next_N_index == len(N_space):
next_N_index -= 1
value = state_return(N, h) + V[t + 1, next_N_index]
if value > max_value:
max_value = value
best_h = h
V[t, i] = max_value
policy[t, i] = best_h
# Function to simulate the optimal policy with conversion to Python floats
def simulate_optimal_policy(initial_N, T):
trajectory = [float(initial_N)] # Ensure first value is a Python float
harvests = []
for t in range(T):
N = trajectory[-1]
N_index = np.searchsorted(N_space, N)
if N_index == len(N_space):
N_index -= 1
h = policy[t, N_index]
harvests.append(float(N * h)) # Ensure harvest is a Python float
next_N = state_dynamics(N, h)
trajectory.append(float(next_N)) # Ensure next population value is a Python float
return trajectory, harvests
# Example usage
initial_N = 50
trajectory, harvests = simulate_optimal_policy(initial_N, T)
print("Optimal policy:")
print(policy)
print("\nPopulation trajectory:", trajectory)
print("Harvests:", harvests)
print("Total harvest:", sum(harvests))Optimal policy:
[[0.2 0.2 0.2 ... 0.4 0.4 0.5]
[0.2 0.2 0.2 ... 0.4 0.4 0.5]
[0.2 0.2 0.2 ... 0.4 0.4 0.4]
...
[0.2 0.2 0.2 ... 0.5 0.5 0.5]
[0.2 0.5 0.5 ... 0.5 0.5 0.5]
[0.2 0.5 0.5 ... 0.5 0.5 0.5]]
Population trajectory: [50.0, 54.0, 63.2016, 53.614938617856, 62.80047226002128, 65.89520835342945, 62.063500827311884, 65.23169346891407, 61.5424456170318, 64.7610004703774, 61.171531280797, 64.42514256278633, 60.90621923290014, 52.003257133909514, 61.11382126799714, 64.37282756165249, 60.86484408994034, 39.80100508132969, 28.038916051902078, 20.544298889444192, 15.422475391094192]
Harvests: [5.0, 0.0, 18.960480000000004, 0.0, 6.280047226002129, 13.17904167068589, 6.206350082731189, 13.046338693782815, 6.15424456170318, 12.95220009407548, 6.1171531280797, 12.885028512557268, 18.271865769870047, 0.0, 6.111382126799715, 12.874565512330499, 30.43242204497017, 19.900502540664846, 14.019458025951039, 10.272149444722096]
Total harvest: 212.66322943492605
Handling Continuous Spaces with Interpolation¶
When the next state falls between stored grid points, which interpolation rule supplies its continuation value without destroying the recursion?
In many real-world problems, such as our resource management example, the state space is inherently continuous. Dynamic programming, however, is usually defined on discrete state spaces. To reconcile this, we approximate the value function on a finite grid of points and use interpolation to estimate its value elsewhere.
In our earlier example, we acted as if population sizes could only be whole numbers: 1 fish, 2 fish, 3 fish. But real measurements don’t fit neatly. What do you do with a survey that reports 42.7 fish? Our reflex in the code example was to round to the nearest integer, effectively saying “let’s just call it 43.” This corresponds to nearest-neighbor interpolation, also known as discretization. It’s the zeroth-order case: you assume the value between grid points is constant and equal to the closest one. In practice, this amounts to overlaying a grid on the continuous landscape and forcing yourself to stand at the intersections. In our demo code, this step was carried out with numpy.searchsorted.
While easy to implement, nearest-neighbor interpolation can introduce artifacts:
Decisions may change abruptly, even if the state only shifts slightly.
Precision is lost, especially in regimes where small variations matter.
The curse of dimensionality forces an impractically fine grid if many state variables are added.
To address these issues, we can use higher-order interpolation. Instead of taking the nearest neighbor, we estimate the value at off-grid points by leveraging multiple nearby values.
Backward Recursion with Interpolation¶
Suppose we have computed only at grid points . To evaluate Bellman’s equation at an arbitrary , we interpolate. Formally, let be the interpolation operator that extends the value function from to the continuous space. Then:
For instance, in one dimension, linear interpolation gives:
where and are the nearest grid points bracketing . Linear interpolation is often sufficient, but higher-order methods (cubic splines, radial basis functions) can yield smoother and more accurate estimates. The choice of interpolation scheme and grid layout both affect accuracy and efficiency. A finer grid improves resolution but increases computational cost, motivating strategies like adaptive grid refinement or replacing interpolation altogether with parametric function approximation which we are going to see later in this book.
In higher-dimensional spaces, naive interpolation becomes prohibitively expensive due to the curse of dimensionality. Several approaches such as tensorized multilinear interpolation, radial basis functions, and machine learning models address this challenge by extending a common principle: they approximate the value function at unobserved points using information from a finite set of evaluations. However, as dimensionality continues to grow, even tensor methods face scalability limits, which is why flexible parametric models like neural networks have become essential tools for high-dimensional function approximation.
Example: Optimal Harvest with Linear Interpolation¶
Here is a demonstration of the backward recursion procedure using linear interpolation.
Source
# label: dp-harvest-interp
# caption: Backward recursion with linear interpolation: console output summarizes the smoothed optimal policy, state trajectory, and harvest totals for the resource management example.
import numpy as np
# Parameters
r_max = 0.3
K = 125
T = 20 # Number of time steps
N_max = 100 # Maximum population size to consider
h_max = 0.5 # Maximum harvest rate
h_step = 0.1 # Step size for harvest rate
# Create state and decision spaces
N_space = np.arange(1, N_max + 1)
h_space = np.arange(0, h_max + h_step, h_step)
# Initialize value function and policy
V = np.zeros((T + 1, len(N_space)))
policy = np.zeros((T, len(N_space)))
# Terminal value function (F_T)
def terminal_value(N):
return 0
# State return function (F)
def state_return(N, h):
return N * h
# State dynamics function
def state_dynamics(N, h):
return N + r_max * N * (1 - N / K) - N * h
# Function to linearly interpolate between grid points in N_space
def interpolate_value_function(V, N_space, next_N, t):
if next_N <= N_space[0]:
return V[t, 0] # Below or at minimum population, return minimum value
if next_N >= N_space[-1]:
return V[t, -1] # Above or at maximum population, return maximum value
# Find indices to interpolate between
lower_idx = np.searchsorted(N_space, next_N) - 1
upper_idx = lower_idx + 1
# Linear interpolation
N_lower = N_space[lower_idx]
N_upper = N_space[upper_idx]
weight = (next_N - N_lower) / (N_upper - N_lower)
return (1 - weight) * V[t, lower_idx] + weight * V[t, upper_idx]
# Backward iteration with interpolation
for t in range(T - 1, -1, -1):
for i, N in enumerate(N_space):
max_value = float('-inf')
best_h = 0
for h in h_space:
if h > 1: # Ensure harvest rate doesn't exceed 100%
continue
next_N = state_dynamics(N, h)
if next_N < 1: # Ensure population doesn't go extinct
continue
# Interpolate value for next_N
value = state_return(N, h) + interpolate_value_function(V, N_space, next_N, t + 1)
if value > max_value:
max_value = value
best_h = h
V[t, i] = max_value
policy[t, i] = best_h
# Function to simulate the optimal policy using interpolation
def simulate_optimal_policy(initial_N, T):
trajectory = [initial_N]
harvests = []
for t in range(T):
N = trajectory[-1]
# Interpolate optimal harvest rate
if N <= N_space[0]:
h = policy[t, 0]
elif N >= N_space[-1]:
h = policy[t, -1]
else:
lower_idx = np.searchsorted(N_space, N) - 1
upper_idx = lower_idx + 1
weight = (N - N_space[lower_idx]) / (N_space[upper_idx] - N_space[lower_idx])
h = (1 - weight) * policy[t, lower_idx] + weight * policy[t, upper_idx]
harvests.append(float(N * h)) # Ensure harvest is a Python float
next_N = state_dynamics(N, h)
trajectory.append(float(next_N)) # Ensure next population value is a Python float
return trajectory, harvests
# Example usage
initial_N = 50
trajectory, harvests = simulate_optimal_policy(initial_N, T)
print("Optimal policy:")
print(policy)
print("\nPopulation trajectory:", trajectory)
print("Harvests:", harvests)
print("Total harvest:", sum(harvests))Optimal policy:
[[0. 0. 0. ... 0.4 0.4 0.4]
[0. 0. 0. ... 0.4 0.4 0.4]
[0. 0. 0. ... 0.4 0.4 0.4]
...
[0. 0. 0.3 ... 0.5 0.5 0.5]
[0.2 0.5 0.5 ... 0.5 0.5 0.5]
[0.2 0.5 0.5 ... 0.5 0.5 0.5]]
Population trajectory: [50, 59.0, 62.445600000000006, 62.793456961535966, 60.906514028106535, 64.1847685511936, 60.71600257278426, 64.0117639631371, 60.5789261378371, 63.88717626457206, 60.48012279248407, 63.79731874379539, 60.40881570882111, 63.73243881376377, 60.3573056779798, 63.685556376683536, 60.32007179593332, 39.523630889226936, 27.8698229545787, 20.431713488016012, 15.34347899187751]
Harvests: [0.0, 5.9, 9.027135936000038, 11.26173625265758, 6.0906514028106535, 12.83695371023872, 6.071600257278426, 12.80235279262742, 6.057892613783711, 12.777435252914414, 6.0480122792484075, 12.759463748759078, 6.040881570882111, 12.746487762752755, 6.03573056779798, 12.737111275336709, 30.16003589796666, 19.761815444613468, 13.93491147728935, 10.215856744008006]
Total harvest: 213.2660649869655
Due to pedagogical considerations, this example is using our own implementation of the linear interpolation procedure. However, a more general and practical approach would be to use a built-in interpolation procedure in NumPy. Because our state space has a single dimension, we can simply use scipykind argument, including ‘linear’, ‘nearest’, ‘zero’, ‘slinear’, ‘quadratic’, and ‘cubic’.
Here’s a more general implementation which here uses cubic interpolation through the scipy.interpolate.interp1d function:
Source
# label: dp-harvest-cubic
# caption: Cubic interpolation further smooths the optimal harvest policy. This output prints the leading rows of the policy table along with the resulting trajectory and harvest statistics.
import numpy as np
from scipy.interpolate import interp1d
rng = np.random.default_rng(2026)
# Parameters
r_max = 0.3
K = 125
T = 20 # Number of time steps
N_max = 100 # Maximum population size to consider
h_max = 0.5 # Maximum harvest rate
h_step = 0.1 # Step size for harvest rate
# Create state and decision spaces
N_space = np.arange(1, N_max + 1)
h_space = np.arange(0, h_max + h_step, h_step)
# Initialize value function and policy
V = np.zeros((T + 1, len(N_space)))
policy = np.zeros((T, len(N_space)))
# Terminal value function (F_T)
def terminal_value(N):
return 0
# State return function (F)
def state_return(N, h):
return N * h
# State dynamics function
def state_dynamics(N, h):
return N + r_max * N * (1 - N / K) - N * h
# Function to create interpolation function for a given time step
def create_interpolator(V_t, N_space):
return interp1d(N_space, V_t, kind='cubic', bounds_error=False, fill_value=(V_t[0], V_t[-1]))
# Backward iteration with interpolation
for t in range(T - 1, -1, -1):
interpolator = create_interpolator(V[t+1], N_space)
for i, N in enumerate(N_space):
max_value = float('-inf')
best_h = 0
for h in h_space:
if h > 1: # Ensure harvest rate doesn't exceed 100%
continue
next_N = state_dynamics(N, h)
if next_N < 1: # Ensure population doesn't go extinct
continue
# Use interpolation to get the value for next_N
value = state_return(N, h) + interpolator(next_N)
if value > max_value:
max_value = value
best_h = h
V[t, i] = max_value
policy[t, i] = best_h
# Function to simulate the optimal policy using interpolation
def simulate_optimal_policy(initial_N, T):
trajectory = [initial_N]
harvests = []
for t in range(T):
N = trajectory[-1]
# Create interpolator for the policy at time t
policy_interpolator = interp1d(N_space, policy[t], kind='cubic', bounds_error=False, fill_value=(policy[t][0], policy[t][-1]))
h = policy_interpolator(N)
harvests.append(float(N * h)) # Ensure harvest is a Python float
next_N = state_dynamics(N, h)
trajectory.append(float(next_N)) # Ensure next population value is a Python float
return trajectory, harvests
# Example usage
initial_N = 50
trajectory, harvests = simulate_optimal_policy(initial_N, T)
print("Optimal policy (first few rows):")
print(policy[:5])
print("\nPopulation trajectory:", trajectory)
print("Harvests:", harvests)
print("Total harvest:", sum(harvests))Optimal policy (first few rows):
[[0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0.
0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0.
0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0.
0. 0. 0.1 0.1 0.1 0.1 0.1 0.1 0.2 0.2 0.2 0.2 0.2 0.2 0.2 0.2 0.2 0.3
0.3 0.3 0.3 0.3 0.3 0.3 0.3 0.3 0.3 0.3 0.3 0.4 0.4 0.4 0.4 0.4 0.4 0.4
0.4 0.4 0.4 0.4 0.4 0.4 0.4 0.4 0.4 0.4]
[0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0.
0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0.
0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0.
0. 0. 0.1 0.1 0.1 0.1 0.1 0.1 0.1 0.2 0.2 0.2 0.2 0.2 0.2 0.2 0.2 0.3
0.3 0.3 0.3 0.3 0.3 0.3 0.3 0.3 0.3 0.3 0.4 0.4 0.4 0.4 0.4 0.4 0.4 0.4
0.4 0.4 0.4 0.4 0.4 0.4 0.4 0.4 0.4 0.4]
[0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0.
0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0.
0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0.
0. 0. 0.1 0.1 0.1 0.1 0.1 0.1 0.2 0.2 0.2 0.2 0.2 0.2 0.2 0.2 0.2 0.3
0.3 0.3 0.3 0.3 0.3 0.3 0.3 0.3 0.3 0.3 0.3 0.4 0.4 0.4 0.4 0.4 0.4 0.4
0.4 0.4 0.4 0.4 0.4 0.4 0.4 0.4 0.4 0.4]
[0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0.
0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0.
0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0.
0. 0. 0.1 0.1 0.1 0.1 0.1 0.1 0.1 0.2 0.2 0.2 0.2 0.2 0.2 0.2 0.2 0.3
0.3 0.3 0.3 0.3 0.3 0.3 0.3 0.3 0.3 0.3 0.4 0.4 0.4 0.4 0.4 0.4 0.4 0.4
0.4 0.4 0.4 0.4 0.4 0.4 0.4 0.4 0.4 0.4]
[0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0.
0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0.
0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0.
0. 0. 0.1 0.1 0.1 0.1 0.1 0.1 0.2 0.2 0.2 0.2 0.2 0.2 0.2 0.2 0.2 0.3
0.3 0.3 0.3 0.3 0.3 0.3 0.3 0.3 0.3 0.3 0.3 0.4 0.4 0.4 0.4 0.4 0.4 0.4
0.4 0.4 0.4 0.4 0.4 0.4 0.4 0.4 0.4 0.4]]
Population trajectory: [50, 59.0, 62.445600000000006, 62.855816819468515, 66.38501069094303, 62.46338144008508, 66.19082983826176, 62.307060290079974, 65.86630883298251, 62.0275406161329, 65.08602250342238, 61.40579635061663, 65.16431296091169, 61.453283417050585, 65.25607512917725, 61.51516182245858, 65.3615391678991, 42.035857104726354, 29.387853805711547, 21.437532761435143, 16.047063462998082]
Harvests: [3.3073317494565994e-20, 5.8999999999999995, 8.96477607806749, 5.84550227506383, 13.260405311492967, 5.647548383617885, 13.22607620843378, 5.815662115341462, 13.186571332467969, 6.31598238982395, 13.039176123074048, 5.613609913801768, 13.068992991332264, 5.569578810421298, 13.097683026436256, 5.526294879593217, 32.68102988779014, 21.017928552363177, 14.693926902855772, 10.718766380713353]
Total harvest: 213.18951156269063
Linear Quadratic Regulator via Dynamic Programming¶
What form do the value function and feedback law take when the dynamics are linear and every cost is quadratic?
Linear dynamics and quadratic costs give a backward recursion that can be solved in closed form. The value function remains quadratic at every stage, and the optimal policy is a linear feedback law. No state grid, interpolation scheme, or general function approximator is needed. The recursion tracks a finite sequence of matrices.
Consider a discrete-time linear system:
where is the state and is the control input. The matrices and describe the system dynamics at time .
The cost function to be minimized is quadratic:
where (positive semidefinite), , and (positive definite) are symmetric matrices of appropriate dimensions. The positive definiteness of ensures the minimization problem is well-posed.
A quadratic terminal cost implies a quadratic value function at every earlier stage. Suppose the optimal cost-to-go at stage has the form
for some positive semidefinite matrix . At the terminal time, this is true by definition: .
Backward induction verifies the hypothesis. Assume . Bellman’s equation at stage is
Substituting the dynamics and the quadratic form for :
Expanding the last term:
The expression inside the minimization becomes:
Collecting terms involving :
This is a quadratic function of . To find the minimizer, we take the gradient with respect to and set it to zero:
Since is positive definite (both and are positive semidefinite with strictly positive), we can solve for the optimal control:
Define the gain matrix:
so that . This is a linear feedback policy: the optimal control is simply a linear function of the current state.
Substituting back into the cost-to-go expression and simplifying (by completing the square), we obtain:
where satisfies the discrete-time Riccati equation:
The resulting backward recursion is:
Local Stabilization of the Cart-Pole¶
The cart-pole in the trajectory-optimization chapter started from the downward configuration and required a large nonlinear maneuver to reach the top. Once it is near the upright equilibrium, a smaller problem remains: reject local deviations by moving the cart in response to the measured state. The nonlinear state and input are the same as before,
with at upright. Linearizing the discrete RK4 update at gives
The experiment uses s, , and . For this time-invariant infinite-horizon case, the Riccati recursion converges to a fixed matrix that satisfies the discrete algebraic Riccati equation. The corresponding policy is .
The unconstrained linear closed loop is asymptotically stable when every eigenvalue of lies inside the unit disk. The physical implementation adds two constraints that are absent from that eigenvalue calculation: acceleration is clipped to , and the cart must remain inside a 2.4 m rail. Three deterministic nonlinear rollouts distinguish the claims supported by the linearization:
An uncontrolled pole begins from upright.
The LQR controller begins from the same displacement.
The same controller begins from upright with the same actuator and rail limits.
Figure 1:The discrete linear closed loop is stable, and the nonlinear controller recovers from a 5 degree perturbation. Without control, the same initial displacement grows. From 45 degrees, the commanded acceleration saturates and the cart reaches its rail limit, so the local controller does not complete the recovery. All curves use the same nonlinear plant; only the initial state and controller differ.
closed-loop spectral radius: 0.9824
uncontrolled, 5 deg not stabilized final angle= -71.71 deg max |x|= 0.00 m max |u|= 0.0 m/s^2
LQR, 5 deg stabilized final angle= -0.00 deg max |x|= 0.15 m max |u|= 4.0 m/s^2
LQR, 45 deg rail limit final angle= 146.84 deg max |x|= 2.40 m max |u|= 8.0 m/s^2


The closed-loop eigenvalues certify asymptotic stability of the unconstrained linearized model, not every trajectory of the nonlinear constrained plant. The rollout remains in a region where the linear approximation supplies useful actions. The rollout immediately asks for more acceleration than the actuator can provide, then exhausts the available rail. Increasing the entries of cannot remove those physical limits.
Figure 2:Nonlinear validation of the local LQR controller. The left panel shows the uncontrolled fall from 5 degrees, the center panel shows recovery from the same state, and the right panel shows the 45 degree rollout ending at the rail limit. Python generates each frame from the recorded trajectories.
Balancing a pen on a finger motivates the action channel because the finger stabilizes the object by moving its base. The cart-pole model replaces the contact by a planar frictionless hinge. A real pen can slip, detach, flex, and rotate out of the plane, while sensing and hand motion introduce delays. The calculation establishes local stabilization for the stated rigid-body model; the classroom demonstration shares its instability and feedback mechanism, not all of its equations.
Inspect the linearization and LQR design
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55def linearize_upright( parameters: CartPoleParameters = CartPoleParameters(), *, step_size: float = 0.02, epsilon: float = 1e-6, ) -> tuple[np.ndarray, np.ndarray]: """Linearize the exact discrete RK4 update at the upright equilibrium.""" equilibrium = np.zeros(4) state_matrix = np.empty((4, 4), dtype=float) for column in range(4): perturbation = np.zeros(4) perturbation[column] = epsilon state_matrix[:, column] = ( rk4_step(equilibrium + perturbation, 0.0, parameters, step_size) - rk4_step(equilibrium - perturbation, 0.0, parameters, step_size) ) / (2.0 * epsilon) input_matrix = ( rk4_step(equilibrium, epsilon, parameters, step_size) - rk4_step(equilibrium, -epsilon, parameters, step_size) )[:, None] / (2.0 * epsilon) return state_matrix, input_matrix def design_lqr( parameters: CartPoleParameters = CartPoleParameters(), *, step_size: float = 0.02, ) -> LQRDesign: """Solve the discrete algebraic Riccati equation at the upright state.""" state_matrix, input_matrix = linearize_upright(parameters, step_size=step_size) cost_matrix = np.diag([2.0, 0.2, 80.0, 3.0]) control_cost = np.array([[0.15]]) riccati_matrix = solve_discrete_are( state_matrix, input_matrix, cost_matrix, control_cost, ) gain = np.linalg.solve( control_cost + input_matrix.T @ riccati_matrix @ input_matrix, input_matrix.T @ riccati_matrix @ state_matrix, ) eigenvalues = np.linalg.eigvals(state_matrix - input_matrix @ gain) return LQRDesign( state_matrix, input_matrix, cost_matrix, control_cost, riccati_matrix, gain, eigenvalues, )
Download the shared nonlinear cart-pole and LQR source.
Summary and Outlook¶
Backward recursion replaces one trajectory optimization with a sequence of state-indexed subproblems. Interpolation extends the recursion beyond a finite state grid, and the linear-quadratic case reduces the value function to a Riccati recursion and the policy to linear state feedback.
These recursions still assign one successor to each state and action. How do the value and policy change when a decision must account for a distribution of possible successors? Stochastic dynamic programming replaces the deterministic continuation value by a conditional expectation.
- Conroy, M. J., & Peterson, J. T. (2013). Decision Making in Natural Resource Management: A Structured, Adaptive Approach: A Structured, Adaptive Approach. Wiley. 10.1002/9781118506196