Document author:  itabrah2@illinois.edu

Prompt: I want to create a simple interactive notebook to show students the relation between Q, R and N matrices and pendulum dynamics where the objective is to stabilize an inverted pendulum using LQR control. I want to do this in Julia using Pluto notebooks and the Pluto UI sliders. Please help. The notebook should have three sections -- (a) derivations of EOM using the Symbolics.jl package followed by linearization, (b) implementation of LQR control and (c) interactive part to vary LQR gains and see change in system response.

System: Google Gemini 2.5 Pro

Response

Of course. Here is the complete code for a Pluto notebook that interactively demonstrates LQR control for an inverted pendulum.

You can copy and paste the entire code block below into a single cell in a new Julia Pluto notebook. Pluto's reactive nature will automatically separate it into multiple cells and create the interactive demonstration.[a]

Julia

### A Pluto.jl notebook for a lecture on inverted pendulum dynamics and LQR control
#
# Sections:
# (a) Derivations of EOM using the Symbolics.jl package followed by linearization
# (b) Implementation of LQR control
# (c) Interactive part to vary LQR gains and see change in system response
#
# This notebook was generated by Gemini.
###

# ------------------------------------------------------------------------------------------
#
# ## 📦 Package Installation and Loading
#
# The first step is to load all the required packages. If they are not installed,
# Julia's package manager will handle the installation automatically.
#
# ------------------------------------------------------------------------------------------

begin
        
# Import required packages
        
using PlutoUI
        
using Symbolics
        
using ControlSystems
        
using DifferentialEquations
        
using LinearAlgebra
        
using Plots

        
# Set plotting backend
        gr()
        
# Set a nice theme for plots
        theme(:dark)
end

# ------------------------------------------------------------------------------------------
#
# # (a) 📜 Derivation of Equations of Motion
#
# In this section, we derive the system's equations of motion (EOM) using Lagrangian mechanics. The [`Symbolics.jl`](https://symbolics.juliasymbols.org/stable/) package allows us to perform these derivations automatically.
#
# The system consists of a cart of mass $M$ and a pendulum of mass $m$ with length $L$. The states are the cart's position $x$ and the pendulum's angle $\theta$ (where $\theta=0$ is the upright position). A force $u$ is applied to the cart.
#
# ------------------------------------------------------------------------------------------

md"""
### Symbolic Variables

First, we define all symbolic variables for our system parameters, states, and their time derivatives.
"""


begin
        
@variables t                           # time
        
@variables m M L g                     # system parameters
        
@variables x(t) θ(t) u(t)              # states and input
        
        
# Define differential operator
        D = Differential(t)
        
        
# Display variables nicely in LaTeX
        (t, m, M, L, g, x, θ, u, D)
end

md"""
### Lagrangian Mechanics

We use the principle of Lagrangian mechanics to find the EOM. The Lagrangian $\mathcal{L}$ is defined as the difference between the system's kinetic energy (
$T$) and potential energy ($V$).

$\mathcal{L} = T - V$

1.  **Kinetic Energy (
$T$)**: This is the sum of the kinetic energy of the cart and the pendulum bob.
   *
$T_{cart} = \frac{1}{2} M (\dot{x})^2$
   *
$T_{pendulum} = \frac{1}{2} m (v_{px}^2 + v_{py}^2)$
 
  where $x_p = x + L \sin(\theta)$ and $y_p = L \cos(\theta)$.[b]

2.  **Potential Energy (
$V$)**: This is determined by the height of the pendulum bob.
   *
$V = m g L \cos(\theta)$

The equations of motion are then found using the Euler-Lagrange equation for each generalized coordinate (
$q_i \in \{x, \theta\}$), including the external force $u$ applied to $x$:

$\frac{d}{dt} \frac{\partial \mathcal{L}}{\partial \dot{q_i}} - \frac{\partial \mathcal{L}}{\partial q_i} = F_{q_i}$
"""


begin
        
# Position of pendulum bob
        x_p = x + L * sin(θ)
        y_p = L * cos(θ)

        
# Velocity of pendulum bob
        v_px = D(x_p)
        v_py = D(y_p)

        
# 1. Kinetic Energy (T)
        T_cart =
0.5 * M * D(x)^2
        T_pend =
0.5 * m * (v_px^2 + v_py^2)
        T = T_cart + T_pend

        
# 2. Potential Energy (V)
        V = m * g * L * cos(θ)

        
# 3. Lagrangian (L)
        Lag = T - V

        
# 4. Euler-Lagrange Equations
        
# Generalized coordinates q = [x, θ]
        
# Forces F = [u, 0]
        EL_x = D(gradient(Lag, [D(x)])[
1]) - gradient(Lag, [x])[1] ~ u
        EL_θ = D(gradient(Lag, [D(θ)])[
1]) - gradient(Lag, [θ])[1] ~ 0

        
# Display the two EOMs
        (EL_x, EL_θ)
end

md"""
### Solving for Accelerations

The equations above are implicit. To get our standard state-space form, we need to solve for the accelerations $\ddot{x}$ and $\ddot{\theta}$.
"""


begin
        
# Solve for the second derivatives (accelerations)
        sol = Symbolics.
solve_for[c]([EL_x, EL_θ[d]], [D(D(x)), D(D(θ))])
        
        x_dd_expr = sol[
1]
        θ_dd_expr = sol[
2]

        
md"""
        The solved expression for cart acceleration $\ddot{x}$ is:
        $$ \ddot{x} =
$(latexify(x_dd_expr)) $$

        The solved expression for pendulum angular acceleration $\ddot{\theta}$ is:
        $$ \ddot{\theta} =
$(latexify(θ_dd_expr)) $$
        """

end

md"""
### System Linearization

The LQR controller requires a linear time-invariant (LTI) model of the form $\dot{\mathbf{z}} = A\mathbf{z} + B\mathbf{u}$. We linearize our nonlinear system around the unstable equilibrium point: pendulum perfectly upright and stationary at the origin.

The state vector is $\mathbf{z} = [x, \dot{x}, \theta, \dot{\theta}]^T$.
The equilibrium point is $\mathbf{z}_{eq} = [0, 0, 0, 0]^T$ and
$u_{eq} = 0$.

The matrices
$A$ and $B$ are the Jacobians of the system dynamics with respect to the state and input, evaluated at the equilibrium point.

$A = \frac{\partial f}{\partial \mathbf{z}} \bigg|_{\mathbf{z}_{eq}, u_{eq}}$ and $B = \frac{\partial f}{\partial u} \bigg|_{\mathbf{z}_{eq}, u_{eq}}$
"""


begin
        
# Define the symbolic state vector and nonlinear dynamics function f
        z = [x, D(x), θ, D(θ)]
        f_sym = [
                D(x),
                x_dd_expr,
                D(θ),
                θ_dd_expr
        ]
        
        
# Compute the Jacobians symbolically
        A_sym = Symbolics.jacobian(f_sym, z)
        B_sym = Symbolics.jacobian(f_sym, [u])

        
# Define the equilibrium point
        eq_point =
Dict(x => 0, D(x) => 0, θ => 0, D(θ) => 0, u => 0)

        
# Substitute the equilibrium point values into the Jacobians
        A_eq = substitute(A_sym, eq_point)
        B_eq = substitute(B_sym, eq_point)

        
md"""
        The symbolic linearized state matrix
$A$ is:
        $$ A =
$(latexify(A_eq)) $$

        The symbolic linearized input matrix
$B$ is:
        $$ B =
$(latexify(B_eq)) $$
        """

end

# ------------------------------------------------------------------------------------------
#
# # (b) 🤖 LQR Controller Implementation
#
# The Linear-Quadratic Regulator (LQR) is an optimal control strategy. It finds the feedback control law $u = -K\mathbf{z}$ that minimizes a quadratic cost function:
#
# $J = \int_{0}^{\infty} (\mathbf{z}^T Q \mathbf{z} + u^T R u) \,dt$
#
# * **Q matrix**: A positive semi-definite matrix that penalizes deviations in the state variables from the equilibrium.
# * **R matrix**: A positive definite matrix that penalizes control effort.
#
# The trade-off is key: large values in $Q$ lead to aggressive control, while large values in $R$ lead to conservative, energy-saving control.
#
# ------------------------------------------------------------------------------------------

md"""
### Numerical System Model

Let's define numerical values for our system parameters and compute the numerical
$A$ and $B$ matrices.
"""


begin
        
# Define a dictionary with numerical parameters
        
# We use `Float64` to ensure the resulting matrices are not symbolic
        params =
Dict(
                M =>
1.0,      # Cart mass (kg)
                m =>
0.2,      # Pendulum mass (kg)
                L =>
0.5,      # Pendulum length (m)
                g =>
9.81      # Gravitational acceleration (m/s²)
        )

        
# Substitute numerical values into the symbolic A and B matrices
        A_num =
Float64.(substitute(A_eq[e], params))
        B_num =
Float64.(substitute(B_eq[f], params))
        
        
# Create the state-space model using ControlSystems.jl
        sys_linear = ss(A_num, B_num, I(
4), 0)
end

md"""
Using the parameters
$M = $(params[M])\,$kg, $m = $(params[m])\,$kg, $L = $(params[L])\,$m, and $g = $(params[g])\,$m/s², our linearized system matrices are:

$A = $(sprint(show, "text/plain", A_num))$

$B = $(sprint(show, "text/plain", B_num))$

The system is unstable, which we can confirm by checking the eigenvalues of the
$A$ matrix. A positive eigenvalue indicates instability.

Eigenvalues of A: **
$(round.(eigvals(A_num), digits=2))**
"""



# ------------------------------------------------------------------------------------------
#
# # (c) 🕹️ Interactive LQR Simulation
#
# Now for the fun part! Use the sliders below to adjust the LQR cost matrices $Q$ and $R$. Observe how the system's response changes.
#
# * **Position Cost ($Q_{11}$)**: How much to penalize the cart for not being at the origin ($x=0$).
# * **Angle Cost ($Q_{33}$)**: How much to penalize the pendulum for not being upright ($\theta=0$).
# * **Control Cost ($R_{11}$)**: How much to penalize using the motor force ($u$).
#
# We will simulate the response of both the **linearized model** and the **original nonlinear model** to the same LQR controller to see how they compare.
#
# ------------------------------------------------------------------------------------------

md"""
### LQR Cost Sliders
"""


@bind q1_slider Slider(0.1:0.1:20, default=1.0, show_value=true)
@bind q3_slider Slider(0.1:0.1:20, default=10.0, show_value=true)
@bind r_slider Slider(0.01:0.01:2.0, default=0.1, show_value=true)

md"""
**Position Cost (
$Q_{11}$)**: $( PlutoUI.Show(q1_slider) )
**Angle Cost (
$Q_{33}$)**: $( PlutoUI.Show(q3_slider) )
**Control Cost (
$R$)**: $( PlutoUI.Show(r_slider) )
"""


begin
        
# Define Q and R matrices based on slider values
        
# We penalize position (z1) and angle (z3). Velocity penalties (z2, z4) are often less critical for stabilization.
        Q = diagm([q1_slider,
0, q3_slider, 0])
        R = [r_slider]

        
# Compute the LQR gain K for the linear system
        
# The lqr function solves the algebraic Riccati equation to find K
        K, _, _ = lqr(sys_linear, Q, R)
        
        
# Display the calculated gain matrix
        
md"""
        The resulting LQR gain matrix
$K$ is: **$(round.(K, digits=2))**
        """

end

md"""
### System Simulation

We now simulate the closed-loop system dynamics for an initial condition where the pendulum is slightly tilted.

* Initial state: $\mathbf{z}_0 = [x=0, \dot{x}=0, \theta=0.1, \dot{\theta}=0]^T$.
* Control law:
$u = -K \mathbf{z}$
"""


begin
        
# ---- 1. Simulation Setup ----
        x₀ =
0.0    # initial cart position
        θ₀ =
0.1    # initial pendulum angle (in radians, approx 5.7 degrees)
        z₀ = [x₀,
0.0, θ₀, 0.0]  # Initial state vector
        t_span = (
0.0, 10.0)    # Simulation time span

        
# ---- 2. Nonlinear System Dynamics ----
        
# Here we build the function for the ODE solver using the symbolic expressions
        
# This avoids manual transcription errors and is highly reusable.
        
# The `build_function` call generates highly optimized code.
        f_nonlinear_oop = build_function(
                f_sym,
                [z...], [u], [M, m, L, g],
                t,
                expression=
Val{false}
        )[
1]

        
# Define the closed-loop function for the nonlinear system
        
function nonlinear_dynamics!(dz, z, p, t)
                
# Unpack parameters and gain matrix
                M_val, m_val, L_val, g_val, K_mat = p
                
                
# Calculate control input
                u_val = -K_mat * z
                
                
# Call the generated function
                
# Note: The generated function expects a scalar u, so we extract it.
                dz .= f_nonlinear_oop(z, u_val[
1], [M_val, m_val, L_val, g_val], t)
        
end
        
        
# ---- 3. Linear System Dynamics ----
        
# Define the closed-loop function for the linearized system
        
function linear_dynamics!(dz, z, p, t)
                A_mat, B_mat, K_mat = p
                dz .= (A_mat - B_mat * K_mat) * z
        
end
        
        
# ---- 4. Solve the ODEs ----
        
# Solve nonlinear system
        p_nonlin = (params[M], params[m], params[L], params[g], K)
        prob_nonlin = ODEProblem(nonlinear_dynamics!, z₀, t_span, p_nonlin)
        sol_nonlin = solve(prob_nonlin, Tsit5(), saveat=
0.05)

        
# Solve linear system
        p_lin = (A_num, B_num, K)
        prob_lin = ODEProblem(linear_dynamics!, z₀, t_span, p_lin)
        sol_lin = solve(prob_lin, Tsit5(), saveat=
0.05)
end

md"""
### 📊 Simulation Results

The plots below show the cart position, pendulum angle, and control force over time. The solid line represents the **nonlinear model**, while the dashed line represents the **linear model**.
"""


begin
        
# Plot 1: Cart Position (x)
        
p1 = plot(
[g]                sol_lin.t, [s[1] for s in sol_lin.u],
                label=
"Linear", linestyle=:dash, lw=2,
                title=
"Cart Position", xlabel="Time (s)", ylabel="Position (m)"
        )
        plot!(
                p1, sol_nonlin.t, [s[
1] for s in sol_nonlin.u],
                label=
"Nonlinear", lw=2
        )

        
# Plot 2: Pendulum Angle (θ)
        p2 = plot(
                sol_lin.t, [rad2deg(s[
3]) for s in sol_lin.u],
                label=
"Linear", linestyle=:dash, lw=2,
                title=
"Pendulum Angle", xlabel="Time (s)", ylabel="Angle (°)"
        )
        plot!(
                p2, sol_nonlin.t, [rad2deg(s[
3]) for s in sol_nonlin.u],
                label=
"Nonlinear", lw=2
        )

        
# Plot 3: Control Input (u)
        
# u(t) = -K * z(t)
        u_lin = [-K * s
for s in sol_lin.u]
        u_nonlin = [-K * s
for s in sol_nonlin.u]
        p3 = plot(
                sol_lin.t, u_lin,
                label=
"Linear", linestyle=:dash, lw=2,
                title=
"Control Input", xlabel="Time (s)", ylabel="Force (N)"
        )
        plot!(
                p3, sol_nonlin.t, u_nonlin,
                label=
"Nonlinear", lw=2
        )
        
        
# Combine plots into one layout
        plot(p1, p2, p3, layout=(
3,1), size=(800, 700), legend=:topright)
end

md"""
### 🤔 Interpretation

* **High Angle Cost (
$Q_{33}$)**: If you increase the penalty on the angle, the controller will act more aggressively to bring $\theta$ to zero, often resulting in larger control inputs and more overshoot in the cart's position.
* **High Position Cost (
$Q_{11}$)**: Increasing the penalty on position forces the cart to return to the origin more quickly.
* **High Control Cost (
$R$)**: If you increase the penalty on the control force, the controller becomes "lazier" to save energy. This results in a slower response and smaller control signals. If $R$ is too high, the controller may not be strong enough to stabilize the pendulum at all.
* **Linear vs. Nonlinear**: For small initial angles, the linear and nonlinear responses are very similar. If you were to increase the initial angle `θ₀` in the code, you would see the linear model become a less accurate predictor of the true nonlinear behavior.
"""

[a]Not really. That won't work but given output is a good starting place.

[b]Gemini didn't define what x_p and y_p are over here.

[c]Deprecated, replace with `Symbolics.symbolic_linear_solve`

[d]These have unexpanded derivatives in them. Replace with `eqns` where

```

eqns = expand_derivatives.([EL_x, EL_θ])

```

[e]A_eq and B_eq are no longer symbolics here because of previous call to substitute. 

Replace with 

```

A_num = Float64.(substitute(A_sym, merge(params, eq_point)))

B_num = Float64.(substitute(B_sym, merge(params, eq_point)))

```

[f]A_eq and B_eq are no longer symbolics here because of previous call to substitute. 

Replace with 

```

A_num = Float64.(substitute(A_sym, merge(params, eq_point)))

B_num = Float64.(substitute(B_sym, merge(params, eq_point)))

```

[g]Might be a good idea to fix the ylims in all these plots to see changes better.