Quadrotor with cable-suspended load

quadrotor animation

This example builds a simple model of a quadrotor that carries a load suspended by a cable. The quadrotor has four arms, each with a thruster at the end. The quadrotor is controlled by three PID controllers: one for altitude, one for roll, and one for pitch (for simplicity, no position controller is included here).

The main body of the aircraft is modeled using a Body, and the arms are modeled using BodyCylinder components. The total inertia of the body and arms are automatically computed using the geometry and density properties of the bodies involved. The thrusters are modeled using a custom component called Thruster that applies a force in the y-direction. The thruster model is kinematical only, and does not model the rotation dynamics of the motors or any aerodynamics. The quadrotor is connected to the world frame using a FreeMotion joint, which allows the quadrotor to move freely in space.

using Multibody
using ModelingToolkit
using ModelingToolkitStandardLibrary.Blocks
using LinearAlgebra
using Plots
using JuliaSimCompiler
using OrdinaryDiffEq
using Test
t = Multibody.t
D = Differential(t)

world = Multibody.world

num_arms = 4 # Number of arms of the rotor craft.
angle_between_arms = 2*pi/num_arms # Angle between the arms, assuming they are evenly spaced.
arm_length = 0.2 # Length of each arm.
arm_outer_diameter = 0.03
arm_inner_diameter = 0.02
arm_density = 800 # Density of the arm [kg/m³].
body_mass = 0.2 # Mass of the body.
load_mass = 0.1 # Mass of the load.
cable_length = 1 # Length of the cable.
cable_mass = 5   # Mass of the cable.
cable_diameter = 0.01 # Diameter of the cable.
number_of_links = 4 # Number of links in the cable.

# PID Controller parameters
kalt = 2.7
Tialt = 3
Tdalt = 0.5

kroll = 0.2
Tiroll = 100
Tdroll = 1

kpitch = 0.2
Tipitch = 100
Tdpitch = 1

@mtkmodel Thruster begin
    @structural_parameters begin
        clockwise = true
    end
    @components begin
        frame_b = Frame()
        thrust3d = WorldForce(resolve_frame = :frame_b, scale=0.1, radius=0.02) # The thrust force is resolved in the local frame of the thruster.
        torque3d = WorldTorque(resolve_frame = :frame_b, scale=0.1, radius=0.02) # Using the thruster also causes a torque around the force axis.
        thrust = RealInput()
    end
    @parameters begin
        torque_constant = 1, [description="Thrust force to torque conversion factor [Nm/N]"]
    end
    @variables begin
        u(t), [state_priority=1000]
        ut(t), [state_priority=1000]
    end
    @equations begin
        thrust3d.force.u[1] ~ 0
        thrust3d.force.u[2] ~ thrust.u
        thrust3d.force.u[3] ~ 0
        torque3d.torque.u[1] ~ 0
        torque3d.torque.u[2] ~ (clockwise ? ut : -ut)
        torque3d.torque.u[3] ~ 0
        thrust.u ~ u
        ut ~ torque_constant*u
        connect(frame_b, thrust3d.frame_b)
        connect(frame_b, torque3d.frame_b)
    end
end

function RotorCraft(; closed_loop = true, addload=true, L=nothing, outputs = nothing, pid=false)
    arms = [
        BodyCylinder(
            r = [arm_length*cos(angle_between_arms*(i-1)), 0, arm_length*sin(angle_between_arms*(i-1))],
            diameter = arm_outer_diameter,
            inner_diameter = arm_inner_diameter,
            density = arm_density,
            name=Symbol("arm$i"),
        ) for i = 1:num_arms
    ]

    @variables begin
        y_alt(t), [state_priority=2]
        y_roll(t), [state_priority=2]
        y_pitch(t), [state_priority=2]
        y_yaw(t), [state_priority=2]
        y_forward(t), [state_priority=2]
        y_sideways(t), [state_priority=2]
        v_alt(t)=0, [state_priority=2]
        v_roll(t)=0, [state_priority=2]
        v_pitch(t)=0, [state_priority=2]
        v_yaw(t)=0, [state_priority=2]
        v_forward(t)=0, [state_priority=2]
        v_sideways(t)=0, [state_priority=2]
        (Ie_alt(t)=0), [description="Integral of altitude error"]
        yIe_alt(t)
    end

    thrusters = [Thruster(name = Symbol("thruster$i"), clockwise = (i-1) % 2 == 0) for i = 1:num_arms]
    @named body = Body(m = body_mass, state_priority = 100, I_11=0.01, I_22=0.01, I_33=0.01, air_resistance=1, isroot=true)

    connections = [
        y_alt ~ body.r_0[2]
        y_roll ~ body.phi[3]
        y_pitch ~ body.phi[1]
        y_yaw ~ body.phi[2]
        y_forward ~ body.r_0[1]
        y_sideways ~ body.r_0[3]
        v_alt ~ D(body.r_0[2])
        v_roll ~ D(body.phi[3])
        v_pitch ~ D(body.phi[1])
        v_yaw ~ D(body.phi[2])
        v_forward ~ D(body.r_0[1])
        v_sideways ~ D(body.r_0[3])
        D(Ie_alt) ~ y_alt
        yIe_alt ~ Ie_alt
        [connect(body.frame_a, arms[i].frame_a) for i = 1:num_arms]
        [connect(arms[i].frame_b, thrusters[i].frame_b) for i = 1:num_arms]
    ]
    systems = [world; arms; body; thrusters]
    if addload
        @named load = Body(m = load_mass, air_resistance=0.1)
        @named cable = Rope(
            l = cable_length,
            m = cable_mass,
            n = number_of_links,
            c = 0,
            d = 0,
            air_resistance = 0.1,
            d_joint = 0.1,
            radius = cable_diameter/2,
            color = [0.5, 0.4, 0.4, 1],
            dir = [0.0, -1, 0]
        )
        push!(systems, load)
        push!(systems, cable)

        push!(connections, connect(body.frame_a, cable.frame_a))
        push!(connections, connect(cable.frame_b, load.frame_a))
    end
    if closed_loop # add controllers

        if pid
            # Mixing matrices for the control signals
            @parameters Galt[1:4] = ones(4) # The altitude controller affects all thrusters equally
            @parameters Groll[1:4] = [-1,0,1,0]
            @parameters Gpitch[1:4] = [0,1,0,-1]

            @named Calt = PID(; k=kalt, Ti=Tialt, Td=Tdalt)
            @named Croll = PID(; k=kroll, Ti=Tiroll, Td=Tdroll)
            @named Cpitch = PID(; k=kpitch, Ti=Tipitch, Td=Tdpitch)

            uc = Galt*Calt.ctr_output.u + Groll*Croll.ctr_output.u + Gpitch*Cpitch.ctr_output.u
            uc = collect(uc)
            append!(connections, [thrusters[i].u ~ uc[i] for i = 1:num_arms])

            append!(connections, [
                Calt.err_input.u ~ -y_alt
                Croll.err_input.u ~ -y_roll
                Cpitch.err_input.u ~ -y_pitch
            ])
            append!(systems, [Calt; Croll; Cpitch])
        else # LQR
            @named feedback_gain = Blocks.MatrixGain(K = L)
            @named system_outputs = RealOutput(nout=length(outputs))
            @named system_inputs = RealInput(nin=num_arms)
            append!(connections, [system_outputs.u[i] ~ outputs[i] for i = 1:length(outputs)])
            append!(connections, [thrusters[i].thrust.u ~ system_inputs.u[i] for i = 1:num_arms])
            push!(connections, connect(system_outputs, :y, feedback_gain.input)) # Connect outputs to controller
            push!(connections, connect(feedback_gain.output, :u, system_inputs)) # Connect controller to inputs
            push!(systems, feedback_gain)
            push!(systems, system_outputs)
            push!(systems, system_inputs)
        end

    end
    @named model = ODESystem(connections, t; systems)
    complete(model)
end
model = RotorCraft(closed_loop=true, addload=true, pid=true)
model = complete(model)
ssys = structural_simplify(multibody(model))
# display(unknowns(ssys))
op = [
    model.body.v_0[1] => 0;
    model.Calt.int.x => 4;
    collect(model.cable.joint_2.phi) .=> 0.1;
]

prob = ODEProblem(ssys, op, (0, 20))
sol = solve(prob, FBDF(autodiff=false), reltol=1e-8, abstol=1e-8)
@test SciMLBase.successful_retcode(sol)

plot(sol, idxs=[model.arm1.frame_b.r_0[2], model.arm2.frame_b.r_0[2], model.arm3.frame_b.r_0[2], model.arm4.frame_b.r_0[2]], layout=4, framestyle=:zerolines)
Example block output
import GLMakie
render(model, sol, 0:0.1:sol.t[end], x=-3, z=-3, y=-1, lookat=[0,-1,0], show_axis=false, filename="quadrotor.gif", framerate=25)

quadrotor animation

The green arrows in the animation indicate the force applied by the thrusters.

LQR control design

Below, we demonstrate a workflow where the model is linearized and an LQR controller is designed to stabilize the quadrotor. We linearize the model using the function named_ss from ControlSystemsMTK, this gives us a linear statespace model with named inputs and outputs. We then design an LQR controller using the lqr function from ControlSystems.jl. Since lqr operates on the state realization of the system, but ModelingToolkit gives no guaratees about what the state realization will be, we specify the LQR penalty matrix in terms of the outputs using the system output matrix $C$.

using ControlSystemsBase, RobustAndOptimalControl, ControlSystemsMTK
quad = RotorCraft(closed_loop=false, addload=false)
quad = complete(quad)
inputs = [quad.thruster1.u; quad.thruster2.u; quad.thruster3.u; quad.thruster4.u]
outputs = [quad.y_alt, quad.y_roll, quad.y_pitch, quad.y_yaw, quad.y_forward, quad.y_sideways, quad.v_alt, quad.v_roll, quad.v_pitch, quad.v_yaw, quad.v_forward, quad.v_sideways, quad.yIe_alt]

op = [
    quad.body.r_0[2] => 1e-32
    quad.v_alt => 1e-32 # To avoid singularity in linearization
    quad.world.g => 9.81
    inputs .=> 1;
] |> Dict

@time lsys = named_ss(multibody(quad), inputs, outputs; op)
rsys = minreal(sminreal(lsys))
C = rsys.C
rank(C) >= rsys.nx || @warn "The output matrix C is not full rank"
Q = Diagonal([ # Output penalty matrix
    30  # Altitude
    1   # Roll
    1   # Pitch
    1   # Yaw
    1   # Forward
    1   # Sideways
    20  # Altitude velocity
    1   # Roll velocity
    1   # Pitch velocity
    1   # Yaw velocity
    10  # Forward velocity
    10  # Sideways velocity
    5   # Altitude integral error
])
R = I(4)
L = lqr(rsys, Symmetric(C'Q*C), R)/C
trunc_zero!(A) = A[abs.(A) .< 1e4eps(maximum(abs, A))] .= 0
trunc_zero!(L)
L
4×13 Matrix{Float64}:
 3.59025  -6.38621   0.0      -0.5  -0.707107  …  -2.45337   0.0      1.11803
 3.59025   0.0       6.38621   0.5   0.0           0.0      -2.45337  1.11803
 3.59025   6.38621   0.0      -0.5   0.707107      2.45337   0.0      1.11803
 3.59025   0.0      -6.38621   0.5   0.0           0.0       2.45337  1.11803
ModelingToolkit.get_iv(i::IRSystem) = i.t
model = RotorCraft(; closed_loop=true, addload=true, L=-L, outputs) # Negate L for negative feedback
model = complete(model)
ssys = structural_simplify(multibody(model))

op = [
    model.body.r_0[2] => 1e-3
    model.body.r_0[3] => 1e-3
    model.body.r_0[1] => 1e-3
    collect(model.cable.joint_2.phi) .=> 1 # Usa a larger initial cable bend since this controller is more robust
    model.world.g => 9.81;
    collect(model.body.phid) .=> 0;
    collect(D.(model.body.phi)) .=> 0;
    collect(model.feedback_gain.input.u) .=> 0;
    model.Ie_alt => -10; # Initialize the integrator state to avoid a very large initial transient. This pre-compensates for gravity
] |> Dict
prob = ODEProblem(ssys, op, (0, 20))
sol = solve(prob, FBDF(autodiff=false))
@test SciMLBase.successful_retcode(sol)
plot(sol, idxs=[model.arm1.frame_b.r_0[2], model.arm2.frame_b.r_0[2], model.arm3.frame_b.r_0[2], model.arm4.frame_b.r_0[2]], layout=4, framestyle=:zerolines)
Example block output
render(model, sol, 0:0.1:sol.t[end], x=-2, z=-2, y=-1, lookat=[0,-1,0], show_axis=false, filename="quadrotor_lqr.gif", framerate=25)

quadrotor with LQR animation

The observant reader may have noticed that we linearized the quadrotor without the cable-suspended load applied, but we simulated the closed-loop system with the load. Thankfully, the LQR controller is robust enough to stabilize the system despite this large model error. Before being satisfied with the controller, we should perform robustness analysis. Below, we compute sensitivity functions at the plant output and input and plot their sigma plots, as well as simultaneous diskmargins at the plant output and input.

linop = merge(op, Dict([
    collect(model.cable.joint_2.phi) .=> 0
    collect(model.body.r_0) .=> 1e-32
    collect(model.body.v_0) .=> 1e-32 # To avoid singularity in linearization
    collect(model.system_outputs.u) .=> 1e-32
    collect(model.feedback_gain.input.u) .=> 1e-32
    ]))
@time "Sensitivity function" S = get_named_sensitivity(model, :y; system_modifier=IRSystem, op=linop)
S = minreal(S, 1e-6)
isstable(S) || @error "Sensitivity function S is not stable"
T = I(S.ny) - S
T = minreal(T, 1e-6)
isstable(T) || @error "Sensitivity function T is not stable"
LT = feedback(T, -I(T.ny))#get_named_looptransfer(model, :y; system_modifier=IRSystem, op)

@time "Comp Sensitivity function" Ti = get_named_comp_sensitivity(model, :u; system_modifier=IRSystem, op=linop)
Ti = minreal(Ti, 1e-6)
isstable(Ti) || @error "Sensitivity function Ti is not stable"
LTi = feedback(Ti, -I(Ti.ny)) # Input loop-transfer function

CS = named_ss(model, :y, :u; op=linop, system_modifier=IRSystem) # Closed-loop system from measurement noise to control signal

w = 2pi.*exp10.(LinRange(-2, 2, 200))
fig_dm = plot(diskmargin(LT, 0.5), label="Plant output") # Compute diskmargin with a positive skew of 0.5 to account for a likely gain increase when the load is dropped
plot!(diskmargin(LTi, 0.5), label="Plant input", titlefontsize=8) # Note, simultaneous diskmargins are somewhat conservative

plot(
    sigmaplot(S, w, hz=true, label="", title="S", legend=false),
    sigmaplot(T, w, hz=true, label="", title="T", legend=false),
    sigmaplot(LT, w, hz=true, label="", title="L", legend=false),
    bodeplot(CS, w, hz=true, label="", title="CS", legend=false, plotphase=false, layout=1),
    fig_dm,
    layout=(2,3), size=(800,500), legend=:bottomright, ylims=(1e-4, Inf),
)
Example block output

While gain and phase margins appear to be reasonable, we have a large high-frequency gain in the transfer functions from measurement noise to control signal, $C(s)S(s)$. For a rotor craft where the control signal manipulates the current through motor windings, this may lead to excessive heat generation in the motors if the sensor measurements are noisy.

The diskmargin at the plant output is small, luckily, the gain variation appears at the plant input where the diskmargin is significantly larger. The diskmargins are visualized in the figure titled "Stable region for combined gain and phase variation". See Diskmargin example to learn more about diskmargins.