What a Robot Actually Is
Sense, decide, act, and why every robot you build is that loop running fast.
By the end of this session you will be able to:
- Describe any robot, real or simulated, as a sense, decide, act loop, and say what fills each of the three stages
- Write a plain Python control loop that runs at a fixed rate and holds its last command between passes
- Predict what happens when the loop runs too slowly, and measure the resulting overshoot instead of guessing at it
A robot is a loop, not a machine
Most people define a robot by its parts: motors, sensors, a computer on board. That definition falls apart in one step. A washing machine has all three. A radio-controlled car has motors, a battery and a receiver, and nobody calls it a robot. What is missing in both cases is a decision the machine makes itself, from something it measured itself, quickly enough to matter.
So use this definition instead, and keep using it for the next twenty-four sessions. A robot is anything that runs this loop:
- Sense. Read the world into numbers.
- Decide. Turn those numbers into a command.
- Act. Send the command to hardware that changes the world.
Then it does it again, before the world has changed much.
Everything else in this course is one of those three boxes getting bigger. slam_toolbox is an elaborate sense. Nav2 is an elaborate decide. A motor driver taking a velocity is a small act. When something misbehaves, and it will, your first question is which of the three stages produced the wrong number, and your second is how fast the loop was running when it did.
The three stages, one at a time
Sense does not give you the world. It gives you a stale, noisy number with units attached. A lidar scan describes what the room looked like when the mirror swept past, tens of milliseconds ago. Wheel encoder counts are ticks, not meters, until you multiply by a wheel radius you measured with a ruler and measured slightly wrong. Every measurement carries a timestamp and an error. Ignore the timestamp and Phase 4 punishes you, because the transform tree you build there exists mostly to answer one question: where was the sensor when it took that reading.
Decide should be a pure function. Latest measurement in, command out, nothing else. Keep it that way as long as you can. A decide stage that also opens files, sleeps, or talks to hardware cannot be run on your laptop without a robot attached, and running it without a robot attached is how you will debug it at two in the morning.
Act sets a value that stays set. This is the part that surprises everyone. When you command a mobile base to drive at 0.4 m/s, you have not told it to move 0.4 m. You have set a number that the motor controller keeps obeying until you replace it. If your program crashes on an unhandled exception, or blocks for two seconds on a slow sensor read, the robot keeps driving at 0.4 m/s into whatever is in front of it. That is why velocity commands in ROS 2 travel as geometry_msgs/msg/Twist, which holds two Vector3 fields, linear and angular, and expresses a velocity, never a distance. It is also why every serious robot has a hardware stop button that no software can talk it out of.
Here is the whole idea as a program. It needs nothing installed beyond Python 3.
import time
PERIOD = 0.05 # seconds, so the loop runs at 20 Hz
def sense():
return 2.0 # meters to the wall, from a real sensor later
def decide(distance):
return 0.0 if distance < 0.30 else 0.4 # meters per second
def act(speed):
print("commanded speed {:.2f} m/s".format(speed))
while True:
started = time.monotonic()
distance = sense()
speed = decide(distance)
act(speed)
time.sleep(max(0.0, PERIOD - (time.monotonic() - started)))Use time.monotonic() and not time.time() for this. The monotonic clock never goes backwards and only differences between two readings are meaningful, which is exactly what you want. Wall clock time can jump when the machine syncs with a time server, and a robot that boots with no network and gets one halfway through a run is an ordinary Tuesday.
The rate is a specification, not a detail
The period between two passes of the loop is time during which the robot is blind, deaf and still moving at whatever you last commanded. The distance it covers blind is one multiplication:
blind distance = commanded speed * loop periodAt 0.4 m/s and 20 Hz that is 2 cm. At 0.4 m/s and 1 Hz it is 40 cm, which is longer than most of the robots in this course. Nothing in your code changes between those two cases. Only the rate does, and the robot goes from stopping politely to hitting a wall.
Save this as wall.py. It simulates a base driving at a wall, updating the world every millisecond and running your control loop at whatever rate you pass on the command line.
import sys
RATE_HZ = float(sys.argv[1]) if len(sys.argv) > 1 else 20.0
STOP_DISTANCE = 0.30 # meters
CRUISE_SPEED = 0.40 # meters per second
SIM_STEP = 0.001 # the world advances 1 ms at a time
distance = 2.0
speed = 0.0
period = 1.0 / RATE_HZ
next_control = 0.0
t = 0.0
while t < 8.0 and distance > 0.0:
if t >= next_control:
measured = distance # sense
speed = 0.0 if measured < STOP_DISTANCE else CRUISE_SPEED # decide
next_control += period # act: speed holds
distance -= speed * SIM_STEP
t += SIM_STEP
print("{:6.1f} Hz final distance {:6.3f} m".format(RATE_HZ, distance))The decision here is the crudest one there is: full speed or full stop, with a threshold in between. That is on purpose. It is enough to make the point that the rate, not the cleverness, is what saves you first.
Try it
Run the file at four rates:
python3 wall.py 20
python3 wall.py 5
python3 wall.py 2
python3 wall.py 1You should see final distances of about 0.300 m, 0.240 m, 0.200 m and 0.000 m. That last one is not a stop, it is a collision: the loop was still cruising when the wall arrived, and the run ended because the distance hit zero.
Now change CRUISE_SPEED to 0.80 and find the lowest whole-number rate that still stops the robot short of the wall. You have succeeded when you can name that rate and justify it with one multiplication before you run it, using the blind distance formula above and the 0.30 m threshold.
Common mistakes
- Reading a velocity command as a distance command. You wrote a speed, so the robot holds that speed forever. If you want it to travel a distance, you have to keep sensing and stop it yourself. Nothing in the stack does this for you.
- Sleeping for the whole period instead of what is left of it. Calling
time.sleep(0.05)after work that already took 0.02 s gives you 14 Hz, not 20 Hz. Subtract the elapsed time, as the skeleton above does, and remember thattime.sleepis allowed to sleep longer than you asked. - Putting slow work inside the loop. One log line per pass, one file write, one blocking network read, and your 20 Hz loop is quietly a 4 Hz loop. Nothing warns you. Measure the actual period and print it when it slips past what you budgeted.
- Blaming the decide stage first. It is the part you wrote, so it feels guilty. Check the sense stage first: wrong units, a stale reading, or a sensor pointed the wrong way explains more failures than bad logic does.
Where this goes next
The next session, Ubuntu the Way ROS Expects It, puts this loop somewhere it can run against real hardware: Ubuntu 24.04 Noble, the single Tier 1 platform for ROS 2 Jazzy Jalisco, which is supported until May 2029. You will install it in a VM or on metal and move around it from the terminal alone, because everything after that assumes you can.