Feedforward control explained
Why a proportional controller is always behind a moving target, and how sending the command the model already knows is needed fixes it. Runnable programs drive the same 60 cm move with feedback alone, feedforward alone and both together, and chart the error of each.
Feedforward is sending the command you already know is needed, instead of waiting for an error to tell you. If you know the robot should be moving at 12 cm/s, and you know roughly what command makes 12 cm/s, send that command now. The feedback loop then has nothing to do except fix the part the model got wrong, which is a much smaller job. Nearly every serious controller is built this way: a feedforward term that makes the motion happen, and a feedback term that cleans up after it. On this page a small robot makes the same 60 cm move five times over, and each demo below is a real program you can change and run.
The chart under each robot shows the error in cm: where the plan says the robot should be, minus where it actually is. Plus means the robot is behind the plan. A good controller keeps that line flat on zero while the move is running, not just at the end of it.
The move the robot is following
Every demo follows the same plan: a trapezoidal profile for a 60 cm move, with a speed limit of 12 cm/s and an acceleration limit of 8 cm/s/s. The plan(t) function gives the position, the speed and the acceleration the move should have at time t. Nothing else changes between the demos except the line that works out the command.
feedback only command = K × error
feedforward only command = model(speed the plan wants)
both command = model(speed the plan wants) + K × error
Feedback alone is always behind
A proportional controller only produces a command when there is an error. So if the command needs to be non zero for the whole move, the error has to be non zero for the whole move. There is no tuning that gets round that. It is what a proportional controller is.
K × e = v so e = v / K
At 12 cm/s with a gain of 2, that is 6 cm behind, for as long as the cruise lasts.
The program
from bugbot import *
connect()
DISTANCE, V, A = 60.0, 12.0, 8.0 # the move: cm, cm/s, cm/s/s
K, DT = 2.0, 0.1 # feedback gain, and the tick
t_acc = V / A
d_acc = V * V / (2 * A)
t_flat = (DISTANCE - 2 * d_acc) / V
total = 2 * t_acc + t_flat
def plan(t):
# where the move should be and how fast, t seconds in
if t < t_acc:
return 0.5 * A * t * t, A * t
if t < t_acc + t_flat:
return d_acc + V * (t - t_acc), V
if t < total:
left = total - t
return DISTANCE - 0.5 * A * left * left, A * left
return DISTANCE, 0.0
start = position()[1]
t = 0.0
while t < total + 2.0:
want, v_ref = plan(t)
error = want - (position()[1] - start)
drive(5 * (K * error), 0, 0) # 100 % is 20 cm/s, so 5 % per cm/s
plot("error", error)
wait(DT)
t = t + DT
stop()
print("ended", round(position()[1] - start, 1), "cm of", DISTANCE)
The error climbs during the ramp, reaches 6.8 cm, and sits between 6.5 and 6.8 cm for the whole cruise. The formula said 6, and the extra 0.7 is itself worth understanding: this robot's motors give about 18.2 cm/s at full command instead of the nominal 20, so the error has to be about a tenth bigger to produce the same speed.
When the plan stops at the end, the robot catches up and finishes 0.3 cm short. That is the thing to notice about feedback alone: the final position is fine, and the robot was about 6.7 cm behind for the five seconds in between. If something else has to be at that place at that time, a moving saw, another robot, a ball, then being late is being wrong.
Raising the gain shrinks the gap but never removes it, and it costs you elsewhere: the drive's quarter second lag turns high gain into overshoot, and sensor noise gets multiplied straight into the motors.
Feedforward alone
Now the other extreme. The command comes only from the plan, through the model, and the robot's position is read only so that the chart has something to draw.
The program
from bugbot import *
connect()
DISTANCE, V, A = 60.0, 12.0, 8.0
DT = 0.1
t_acc = V / A
d_acc = V * V / (2 * A)
t_flat = (DISTANCE - 2 * d_acc) / V
total = 2 * t_acc + t_flat
def plan(t):
if t < t_acc:
return 0.5 * A * t * t, A * t
if t < t_acc + t_flat:
return d_acc + V * (t - t_acc), V
if t < total:
left = total - t
return DISTANCE - 0.5 * A * left * left, A * left
return DISTANCE, 0.0
start = position()[1]
t = 0.0
while t < total + 2.0:
want, v_ref = plan(t)
error = want - (position()[1] - start) # for the chart only
drive(5 * v_ref, 0, 0) # the model, and nothing else
plot("error", error)
wait(DT)
t = t + DT
stop()
print("ended", round(position()[1] - start, 1), "cm of", DISTANCE)
The error grows all the way through the move, because nothing is watching it. The model says 100 percent is 20 cm/s and the robot does 18.2, so the robot is 9 percent slow all the way and the shortfall piles up: 8.2 cm at the worst point, 6.3 cm at the end. About a centimetre of the total comes from the dead band, because the first and last 0.4 s of the ramps ask for under 15 percent, which does nothing at all.
Notice what the shape of that error tells you. It drifts. A drift means the model is wrong by a steady fraction, and a model error is something you can measure once and fix for good, which is exactly what the last two demos do.
Both together
One line changes: the two terms are added.
The program
from bugbot import *
connect()
DISTANCE, V, A = 60.0, 12.0, 8.0
K, DT = 2.0, 0.1
t_acc = V / A
d_acc = V * V / (2 * A)
t_flat = (DISTANCE - 2 * d_acc) / V
total = 2 * t_acc + t_flat
def plan(t):
if t < t_acc:
return 0.5 * A * t * t, A * t
if t < t_acc + t_flat:
return d_acc + V * (t - t_acc), V
if t < total:
left = total - t
return DISTANCE - 0.5 * A * left * left, A * left
return DISTANCE, 0.0
start = position()[1]
t = 0.0
while t < total + 2.0:
want, v_ref = plan(t)
error = want - (position()[1] - start)
drive(5 * (v_ref + K * error), 0, 0)
plot("error", error)
wait(DT)
t = t + DT
stop()
print("ended", round(position()[1] - start, 1), "cm of", DISTANCE)
The worst error drops from 6.8 cm to 1.8 cm, and through the cruise it sits at about half a centimetre. That number is predictable too. The model is 9 percent out, so what is left for the feedback to supply is 9 percent of 12 cm/s, and the error needed to produce it is 0.09 × 12 / (0.91 × 2), which is 0.6 cm. Feedback no longer has to make the motion happen. It only has to make up the difference.
That is the division of labour the whole of control engineering is built on:
- Feedforward makes it happen. It is fast, it costs nothing in stability because it is not inside the loop, and it is only as good as your model.
- Feedback fixes what feedforward got wrong. It handles the things no model knows: a slope, a sticky wheel, a flat battery, somebody's foot.
Neither one is enough on its own. Feedforward alone drifts, because nothing measures the result. Feedback alone lags, because it needs an error before it acts.
What goes into the model
The feedforward term is your model of the machine, run backwards: you want this motion, so what command produces it? Every term you can name makes it better.
- The gain. The basic term, and the one every demo above uses:
command = 100 × v / v_max. Using the nominalv_maxleaves you the 9 percent seen above. Using the robot's own measured 18.2 cm/s removes it. - The lag, or acceleration. The drive takes about a quarter of a second to reach the speed it is asked for, so during a ramp the robot is always a little behind. Asking for
v + τ × ainstead ofvcancels it. On a machine where inertia is what fights you rather than a lag, the same term is writtenm × a, and it is the largest term in a fast robot arm. - Gravity. An arm holding itself out horizontally needs a command just to stay still, and that command changes with the angle:
G × cos(angle). A lift needs one that changes with the load. Neither has anything to do with error, so neither belongs in the feedback path. Gravity compensation is why a good robot arm can be pushed around by hand with the motors on. - Friction. Below 15 percent this robot's motors do nothing at all, which is its version of stiction. The feedforward answer is a dead band inverse: whenever you want the machine to move at all, start the command at the dead band and add your request on top. Coulomb friction, which is roughly constant and always opposes motion, gets the same treatment with the sign of the speed.
Here is the same move with two of those terms: the measured gain, and the lag term. There is still no feedback at all.
The program
from bugbot import *
connect()
DISTANCE, V, A = 60.0, 12.0, 8.0
DT, TAU = 0.1, 0.25
GAIN = 18.2 # cm/s at 100 %, measured on this robot
t_acc = V / A
d_acc = V * V / (2 * A)
t_flat = (DISTANCE - 2 * d_acc) / V
total = 2 * t_acc + t_flat
def plan(t):
# position, speed and acceleration the move should have
if t < t_acc:
return 0.5 * A * t * t, A * t, A
if t < t_acc + t_flat:
return d_acc + V * (t - t_acc), V, 0.0
if t < total:
left = total - t
return DISTANCE - 0.5 * A * left * left, A * left, -A
return DISTANCE, 0.0, 0.0
start = position()[1]
t = 0.0
while t < total + 2.0:
want, v_ref, a_ref = plan(t)
error = want - (position()[1] - start) # for the chart only
drive(100 * (v_ref + TAU * a_ref) / GAIN, 0, 0)
plot("error", error)
wait(DT)
t = t + DT
stop()
print("ended", round(position()[1] - start, 1), "cm of", DISTANCE)
Open loop, never once looking at where the robot is, the error stays under 0.7 cm and the move finishes 0.3 cm short. Compare that with the 8.2 cm of the first feedforward demo. The controller did not change. The model did.
Everything together
Put the feedback term back on top of the good model and you have what a real motion controller does.
The program
from bugbot import *
connect()
DISTANCE, V, A = 60.0, 12.0, 8.0
K, DT, TAU = 2.0, 0.1, 0.25
GAIN = 18.2 # cm/s at 100 %, measured on this robot
t_acc = V / A
d_acc = V * V / (2 * A)
t_flat = (DISTANCE - 2 * d_acc) / V
total = 2 * t_acc + t_flat
def plan(t):
if t < t_acc:
return 0.5 * A * t * t, A * t, A
if t < t_acc + t_flat:
return d_acc + V * (t - t_acc), V, 0.0
if t < total:
left = total - t
return DISTANCE - 0.5 * A * left * left, A * left, -A
return DISTANCE, 0.0, 0.0
start = position()[1]
t = 0.0
while t < total + 2.0:
want, v_ref, a_ref = plan(t)
error = want - (position()[1] - start)
drive(100 * (v_ref + TAU * a_ref + K * error) / GAIN, 0, 0)
plot("error", error)
wait(DT)
t = t + DT
stop()
print("ended", round(position()[1] - start, 1), "cm of", DISTANCE)
The error never leaves the band between minus 0.34 and plus 0.30 cm. On the same move, feedback alone was 6.8 cm behind and feedforward alone was 8.2 cm behind. Neither half is doing anything clever. They are each doing the job they are good at.
What feedforward cannot do
It cannot see. If the robot is jammed against a chair leg, the feedforward term will happily command cruise speed for the rest of the move and report nothing wrong. Anything that depends on what actually happened has to come through the feedback path, which is why the loop is never optional and why the error is still the thing to plot.
It also cannot fix a model that is wrong in a way you did not think of. A feedforward term built on a gain you measured once, on a full battery, on a clean mat, is worth less on a flat battery and a dusty one. Feedback is what absorbs the difference, and a growing error on the chart is the machine telling you the model has drifted.
Where this is taught
- Feedforward and feedback is the full lesson, with the arithmetic of
e = v / Kand where each model term comes from. - Proportional control and The integral term are the feedback half, and why P alone leaves an error behind.
- A command is a request is the gain, the dead band and the lag that the model is made of.
- Measuring your robot is where the 18.2 cm/s in the last demos comes from, measured rather than assumed.
- Calibration does the same job for a whole robot, and Fitting a model to data does it with least squares.
- Velocity profiles makes the plan these demos follow, and the motion profiles guide drives the same 60 cm move without any feedback at all.
- How far behind measures the gap between the plan and the robot, which is the chart on this page.
Questions
What is feedforward control?
Sending a command worked out from what you want to happen, rather than from an error that has already happened. A model of the machine is run backwards: you want this speed, so this is the command that produces it. Feedforward acts before any error appears, so it is not in the feedback loop and cannot make it unstable.
What is the difference between feedforward and feedback?
Feedback measures the result and corrects what went wrong, so it always needs an error to act on. Feedforward predicts the command needed and sends it, so it acts immediately but never checks the result. Feedback is only as good as its gain and its loop rate; feedforward is only as good as its model. Real controllers use both.
Why does a proportional controller lag behind a ramp?
Because it only produces a command when there is an error. Following a ramp needs a steady command, so it needs a steady error, and that error settles at v / K: the speed of the ramp divided by the gain. On this page, 12 cm/s with a gain of 2 leaves the robot about 6.7 cm behind for the whole cruise.
Does feedforward remove the need for a feedback loop?
No. Feedforward cannot see, so anything the model does not know about, a slope, a jam, a flat battery, goes uncorrected. The demos here show feedforward alone drifting 6.3 cm short of the mark. It takes both: feedforward to make the motion, feedback to clean up.
What is a gravity feedforward term?
A command sent purely to hold a weight up, worked out from the geometry rather than from an error. For a robot arm it changes with the angle, roughly G × cos(angle), so the feedback loop no longer has to carry the weight as a permanent error. It is what lets a good arm be pushed around by hand with its motors on.
What is a friction feedforward term?
A command added to overcome friction before the machine has to be dragged through it. On this robot the motors do nothing below 15 percent, so the term is a dead band inverse: any request to move at all starts at 15 percent and goes up from there. For Coulomb friction, which is roughly constant and opposes motion, the term is a constant with the sign of the speed.
How do you add feedforward to a PID controller?
Add the term outside the loop: command = feedforward(what the plan wants) + PID(error). Nothing about the PID changes, and its gains usually do not need retuning, because the error it sees is smaller from the first tick. In motion control the feedforward is usually a velocity term plus an acceleration term, with a friction term underneath.
Is feedforward the same as open loop control?
Feedforward on its own is open loop: the command depends only on the plan and the model. The difference is what people mean by the words. Open loop usually describes a whole controller that never measures anything. Feedforward describes one term inside a controller that does measure, and whose feedback part is still there to catch what the model missed.
Learn it step by step
These lessons build the same ideas one at a time, each with tasks the simulator marks.
- U10.5 Feedforward and feedback Following a trajectory, University
- U5.2 Proportional control Feedback control, University
- U5.4 The integral term Feedback control, University
- U1.2 A command is a request The robot as a system, University
- U1.6 Measuring your robot The robot as a system, University
- U3.4 Calibration Odometry and drift, University
- U10.2 Velocity profiles Following a trajectory, University
- U10.6 How far behind Following a trajectory, University
- U12.2 Fitting a model to data Learning, and the capstone, University