Sense, decide, act

The control loop, its period, and the deadman that stops a robot whose program has gone quiet.

U1.1The robot as a systemUniversity20 min

Do this lesson in the simulator

Every robot program you will write in this course is the same shape underneath. Read the sensors. Work out what to do. Tell the motors. Wait a moment. Do it again.

while running:
    measurement = sense()
    command = decide(measurement)
    act(command)
    wait(period)

That is the control loop, and the rest of the course is about what goes inside decide.

Five turns of a 10 Hz loop on a timeline: a short burst of work, a long wait, and the distance ahead changing all the time while the program only sees it once a tickprogramwaitwaitwaitwaitwaitsense, decide, actdistanceaheadthe real distance, all the timewhat the program last read11.11.21.31.41.5time, seconds
The first loop with the robot already driving forward(60), so the distance ahead is shrinking. Once every 0.1 s the program reads, decides and tells the motors, then waits. In between, it works from its last reading while the real distance carries on changing.

A loop you can watch

from bugbot import *
connect()

# ten times a second for three seconds
for tick in range(30):
    ahead = distance()
    print(tick, ahead)
    wait(0.1)

Run this in the simulator

Thirty readings, a tenth of a second apart, and every one of them is 91.0: the wall is 91 cm away and nothing is moving. The simulator's depth sensor has no noise unless you ask for it with set_noise(); it just reads to the nearest centimetre. A real one is not, and Module U4 is about what to do with that.

The period

wait(0.1) sets the period of the loop: 0.1 seconds, so the loop runs at 10 Hz. Everything else follows from it.

  • A controller can only react as often as its loop runs. At 10 Hz the robot does whatever it was last told for a tenth of a second, whatever the world does in between.
  • The measurements arrive at that rate too, so anything that happens faster than about half the loop rate cannot be seen properly. That is the sampling theorem, and it is the reason a fast disturbance can look like a slow one.
  • Real loops are not free. Reading the camera takes time, and wait() is what is left over.
A 9 Hz wobble read 10 times a second looks like a 1 Hz wobble00.250.50.751time, secondsthe real wobble, 9 a secondwhat the loop sees, 1 a second
A wobble 9 times a second, read by a 10 Hz loop (the dots). The readings join up into a slow wobble once a second that is not there at all. At 10 Hz, only things slower than 5 times a second come through as they really are.

The deadman

The robot stops itself half a second after it last heard from your program. A program that crashes, or wanders off into a long calculation, leaves a robot that stops rather than one that drives into a wall. When a program finishes normally, the simulator switches the motors off at once. When it crashes, the simulator does what the robot does: nothing tells the motors, so the robot carries on until the deadman stops it.

from bugbot import *
connect()

forward(60)
wait(1)
print("about to crash at", position())
speed = 60 / 0          # a crash: ZeroDivisionError

Run this in the simulator

It prints about to crash at (-0.2, 8.4) and then the error, but watch the replay: the robot keeps driving. Half a second after the program last spoke to it, the deadman switches the motors off, and it rolls to a stop 16 cm from the start, nearly twice as far as it had got when the program died.

wait() counts as hearing from the program, so a command lasts as long as the program is still running and still waiting:

from bugbot import *
connect()

forward(60)
wait(2)          # wait() keeps the deadman fed, so this drives for the full 2 s
print("after 2 s:", position())

Run this in the simulator

It prints after 2 s: (-0.2, 19.3): two seconds at 60, less the quarter second it takes to get up to speed. This matters for how you write the loop: a command is not a promise that lasts beyond the program. The moment your code stops talking to the robot, by crashing or by getting stuck, the robot gives up. A loop that drives keeps saying so, and keeps waiting in small steps.

Distance from the start against time for the program that crashes and the program that keeps waiting00.511.522.5081624time, secondsdistance, cmcrashdeadman, 0.5 s laterstill waiting: 19.3 cm at 2 scrashed: stops at 16 cm
The two programs drive the same way until 1.5 s, although one of them died at 1 s. Nothing stops a crashed robot until the deadman runs out, 0.5 s after the program last spoke to it.

Blocking and non-blocking

Two ways to say the same thing:

forward(60, distance=40) blocking: comes back when the robot has gone 40 cm
forward(60) then wait(0.1) non-blocking: sets the motors and returns at once

Blocking calls are convenient and are what you have used until now. Every controller in this course uses the non-blocking form, because a controller has to be able to change its mind half way.

Task: run a loop at 10 Hz

Without driving, read the depth sensor thirty times at ten times a second, print each reading, and print how long the loop actually took on average, as period: 0.1.

from bugbot import *
connect()

# clock() is the seconds since the program started
start = clock()

Challenges

  1. Print clock() inside the loop. Is the spacing exactly 0.1 s?
  2. Change the wait to 0.02 s and run it. What is the period now, and what does that tell you about how much work the loop itself costs?
  3. Put forward(60) in a loop of thirty with no wait at all in it. Why does the robot not move in the simulator, and what would a real BugBot do?