Project: the kidnapped robot
No idea where it starts. Work it out, then drive somewhere on purpose.
Do this lesson in the simulatorPrint it: worksheet (PDF) · answers (PDF) · as a web page
Guide: Particle filters explained
The robot wakes up somewhere on the mat. It is not told where. It has to work that out, and then drive to the green corner on purpose.
This is the standard hard case in the literature, and it is a fair test of everything in the module: a belief that starts as "anywhere", measurements that cut it down, resampling that keeps the good guesses without killing the diversity, and a controller that then acts on the answer.
Two phases
Phase one: find out where you are. Stand still, weighing and resampling until the cloud agrees with itself. One reading of the far wall leaves a whole stripe of the mat possible: it finds y and says nothing about x. Driving forward and reading the same wall again only finds y again. What cuts the stripe down is a reading from a different direction: the pair of readings is far more informative than either alone.
One sensor, one axis. The depth sensor measures the wall it is pointing at and nothing else, so a cloud weighed against it can only ever pin down one coordinate. Facing the far wall square on, the reading is 200 - y. Turn ninety degrees to the left, finish the turn, and the same arithmetic reads x instead. Two clouds, one after the other, and the robot knows both. Neither formula is right part way through the turn, which is the trap in this project (see below).
Phase two: go somewhere. Now the estimate is in mat coordinates, so the target can be too. Steer towards it with the inverse kinematics from U2, and keep the filter running all the way, so that the estimate improves rather than decaying into dead reckoning. Driving straight towards or away from a wall keeps the robot square on to it, so the readings stay usable; only the turns need to be skipped.
What to watch on the chart
- The spread collapsing during phase one. If it does not, the robot needs to move before it can know anything.
- The spread staying small during phase two. If it climbs, the measurements have stopped being informative and you are dead reckoning again.
- A sudden jump in the estimate. Either the cloud had two clusters and has just made its mind up, or a reading the model cannot explain, such as one taken during a turn, has dragged it somewhere. Whether it is right is the interesting question.
The structure
from bugbot import *
import math, random
connect()
random.seed(1)
DT, N, SIGMA = 0.1, 400, 3.5
particles = [random.uniform(0, 200) for i in range(N)]
weights = [1.0 / N] * N
def weigh(measured):
# standing still, square on to the far wall: the reading is 200 - y
global weights
w = [math.exp(-(((200 - y) - measured) ** 2) / (2 * SIGMA * SIGMA)) + 1e-12 for y in particles]
total = sum(w)
weights = [v / total for v in w]
def resample(jitter=0.8):
global particles, weights
step, r, c, i, fresh = 1.0 / N, random.uniform(0, 1.0 / N), weights[0], 0, []
for m in range(N):
u = r + m * step
while u > c and i < N - 1:
i += 1
c += weights[i]
fresh.append(particles[i] + random.gauss(0, jitter))
particles, weights = fresh, [1.0 / N] * N
for i in range(20):
weigh(distance())
resample()
mean = sum(w * y for w, y in zip(weights, particles)) # once, not once per particle
plot("spread", math.sqrt(sum(w * (y - mean) ** 2 for w, y in zip(weights, particles))))
# each guess is only a y, so draw it as a stripe across the mat
draw("particles", [((i + 0.5) * 200 / N, y) for i, y in enumerate(particles)], "blue")
wait(DT)
print("I think I am at y =", round(sum(w * y for w, y in zip(weights, particles)), 1))
The robot is at y = 120, and the cloud says 121.0. The blue stripe on the mat is the answer so far: the right y, and every x still possible.
Readings while turning
Phase one is done standing still and square on to the far wall, which is exactly when 200 - y is true. The turn to the left wall is where a filter goes wrong. Part way round, the sensor looks at a wall on a slant and reads long: 111 cm at 45 degrees and 152 cm at 60 degrees, from this spot 80 cm from the far wall and 150 cm from the left one. Neither 200 - y nor x explains those readings, and a filter that weighs its cloud against them anyway moves the cloud to wherever they seem to point.
This cell starts the x cloud before the turn, and weighs it every tick unless the robot is turning.
from bugbot import *
import math, random
connect()
random.seed(1)
# change this and press Run
SKIP_WHILE_TURNING = True
DT, N, SIGMA = 0.1, 400, 3.5
FOUND_Y = 121 # what phase one found, only for drawing
particles = [random.uniform(0, 200) for i in range(N)] # guesses at x
def weigh_and_resample(measured, jitter=0.8):
global particles
# facing the left wall square on, the reading is x
w = [math.exp(-((x - measured) ** 2) / (2 * SIGMA * SIGMA)) + 1e-12 for x in particles]
total = sum(w)
step, r, c, i, fresh = 1.0 / N, random.uniform(0, 1.0 / N), w[0] / total, 0, []
for m in range(N):
u = r + m * step
while u > c and i < N - 1:
i += 1
c += w[i] / total
fresh.append(particles[i] + random.gauss(0, jitter))
particles = fresh
drive(0, 0, -30) # turn left, towards the left wall
turning = True
for tick in range(60):
h = odometry()[2]
if turning and 180 < h < 278: # stop a little early: it coasts
stop()
turning = False
if not (turning and SKIP_WHILE_TURNING):
weigh_and_resample(distance())
plot("x estimate", sum(particles) / N)
# the truth, for the chart only: position() counts from the
# start, which was x = 150 on the mat
plot("robot x", 150 + position()[0])
draw("particles", [(x, FOUND_Y) for x in particles], "blue")
wait(DT)
stop()
mean = sum(particles) / N
spread = (sum((x - mean) ** 2 for x in particles) / N) ** 0.5
print("found x", round(mean, 1), "spread", round(spread, 1))
print("the robot is at x", round(150 + position()[0], 1))
With the readings skipped, the cloud sits untouched over the whole mat until the turn ends at 2.4 s, and the first reading of the left wall pulls it straight onto the robot: it finds x = 150.6 with a spread of 1.8 cm, and the robot is at 149.9.
Set SKIP_WHILE_TURNING = False and run it again. Now the cloud is weighed first against the far wall (it is still facing it when the cell starts), then against the slanted readings, and by 1.5 s it has collapsed around x = 92. When the robot does face the left wall and reads 150, no guess is anywhere near 150, every weight is the same tiny floor, and resampling has nothing to choose between. It ends at x = 92.2 with a spread of 5.4 cm, 58 cm from the robot and no idea that it is wrong.
The rule for the project: weigh only while the robot is square on to the wall your formula is about. Stop, turn, wait for the turn to finish, then start weighing again. The gyro heading from odometry() is good enough to know when the turn is over: here it said 267.3 degrees when the robot was really at 268.7, and it drifts by 2 to 3 degrees over a longer run, which the sensor's cone forgives. The stop at 278 rather than 270 is because the robot keeps turning for a moment after stop().
Task: the kidnapped robot
Work out where the robot woke up, print it as found:, and then drive into the green corner. position() is not allowed anywhere.
from bugbot import *
import math, random
connect()
DT, N, SIGMA = 0.1, 400, 3.5
particles = [random.uniform(0, 200) for i in range(N)]
Challenges
- Shuffle 20 cm sideways during phase one, still facing the far wall, and keep weighing. Does the cloud collapse any faster, or learn anything about
x? Why not? - Inject 2 percent random particles every tick, and half way through the run, pick the robot up in your head: set the true position somewhere else by driving it there with the filter off. Does it recover?
- Track heading as well as position in each particle. What breaks, and what does it cost?
What comes next
Localisation assumed the map was known. U8 asks where the map comes from, which is the other half of the problem, and the point at which the two together get the name SLAM.