Programming for Robotics 1 section 7 built a seam between the robot logic and the world, three methods (range_m, ticks, set_duty), and tested the logic against fakes that were one line of world each: a scripted list of ranges, a wall in one dimension. Programming for Robotics 2 made the other side of the seam real with PicoHardware. This chapter builds the third thing that plugs into the same seam, and the one the rest of the course leans on: a simulator, a small two-dimensional world with a differential-drive robot that really drives around in it, whose wheels turn, whose range sensor sees walls and posts, whose encoders count whole ticks and whose readings have noise. It is written in this chapter, in about 330 lines of standard-library Python, in stages that you can run one after another, and it ends as one file, sim.py (the reference listing of section 14). From here on, “the simulator from Programming for Robotics 3” means that file, and later chapters import it instead of rebuilding it.
What was run, and what was not
Every Python block in this chapter that has an output below it was run with CPython 3 in one session, in the order of the page, and the output shown is what it printed. The first blocks build sim.py piece by piece; the demonstration blocks after them use the pieces built so far. The blocks also use the Config, clamp and Robot code of Programming for Robotics 1 section 6 and the WallWorld and simulate of its section 7: put those in a file robot.py next to sim.py (or in the same session) before you start. The simulator itself imports nothing from robot.py. The numbers that describe the robot are the build robot of Robotics Fundamentals 10 section 9, as before. Some numbers are assumptions chosen for the demonstration, not measurements: a range-sensor noise of 5 mm, a motor time constant of 0.08 s, a 9 cm body radius. Replace them with measurements of your own robot (section 13 says how). Nothing here was run on a robot, and the simulator is for CPython on a PC: it uses random.Random and collections.deque, which a Pico does not need and may not have in full.
1. Why simulate, and what a simulator cannot tell you
A simulator is a program that pretends to be the world and the robot’s hardware. The robot code does not know the difference, because the contract between them is the three methods of Programming for Robotics 1 section 7. What it buys:
Property
Real robot
Simulator
Speed
A 14 s test takes 14 s, plus carrying the robot back and resetting it
As fast as the PC computes. The 14 s runs of section 10 are 700 passes each and finish in a fraction of a second (about 0.02 s each on the machine used to write this chapter; yours will differ), so a test can run thousands of times
Safety
A bug drives into a wall, a table edge or a person
A bug draws a line through a wall on a map
Repeatability
Never the same twice: the floor, the battery and the sensor noise all differ
Identical with the same seed, so one thing can be changed at a time and the effect seen cleanly
What you can see
Only what the sensors report
The true pose and every hidden quantity, next to the readings, so the error is measurable
Faults on demand
Unplug a wire at the right instant, and hope
One parameter: a silent sensor, a late sensor (latency_s), a slipping wheel (scale_right)
Cost of a crash
A broken part and an hour
Nothing
Truth
It is the real thing
An approximation: whatever the model leaves out cannot go wrong in it
The last row is the catch, and it has a name, the reality gap: the difference between how the simulated robot behaves and how the real one does. Every model leaves things out (section 13 lists the ones this simulator leaves out), and code can be tuned to the simulator’s quirks until it works only there. The right use is therefore specific: a simulator is for finding logic bugs, running regression tests, comparing designs and choosing first values of parameters, quickly and safely. It is not for proving that a robot works. A passing simulation is evidence, and the final test is always the robot.
Passing in the simulator is not passing on the robot
If the code only ever ran against a simulator, then every assumption the simulator makes is an assumption the code has never been tested against: perfect Gaussian noise with no outliers, a sensor that is never blocked, motors that respond smoothly. The fix is not a better simulator (there is always a gap), but the habit of section 13: list what the simulator leaves out, decide which of those could break this program, and test those on the real robot.
Seeds: repeatable, and still random
Real sensors are noisy, so the simulated ones must be too, or the code is never tested against noise. Python’s random.Random(seed) makes a stream of numbers that looks random but is the same every time for the same seed. That gives both properties: a seed is a replay button (a failing run can be reproduced exactly, then debugged), and a different seed is a different draw of the noise (so a test can be repeated over 100 seeds to see the spread, not just one lucky run). Three rules keep it that way, and the code follows all of them:
No global random state. Not random.random() or random.seed(1) at the top of a file. Every random thing owns a random.Random object, so adding one more sensor does not change the numbers another one draws (given the same seed, only the order in which sensors are created matters).
No wall clock. Time in the simulator is a number the simulator advances itself, in whole microseconds (section 7), never time.monotonic(). A run then does not depend on how fast the PC is, or on what else it is doing.
Draw the same numbers whatever happens. A sensor draws its noise every time it measures, even when it then reports “no echo”, so a change of geometry does not shift the random stream of later readings.
2. The design: six small pieces and one contract
The simulator is a stack of small classes, each with one job and a clear input and output, as in Programming for Robotics 1 section 6. The robot code sees only the top box.
Robot.update(dt_s) the Robot of Programming for Robotics 1, unchanged
| ^
| | range_m() ticks() line() bump() <- readings
| | set_duty(left, right) -> commands
v |
+---------------------------------------------------------------------------+
| Simulator the hardware interface, in software |
| |
| SimClock now_s(), advance(dt_s): fixed steps, whole microseconds |
| |
| RobotModel true pose (x, y, theta), wheel angles |
| | motor lag, unicycle (arc) update |
| v |
| World walls, boxes, posts, painted lines |
| ^ ray casts, contact tests |
| | |
| Sensors RangeSensor, Encoders, LineSensor, BumpSensor |
| noise, limits, rate and delay; each with its own Random |
+---------------------------------------------------------------------------+
|
+--> Recorder (CSV), ascii_map (a picture), run (the loop), OdometryTracker (a measuring tool)
The pieces and the order they are built in:
Piece
Job
Section
Geometry functions
Where does a ray hit a segment or a circle? Which point of a segment is nearest?
3
World
The arena: walls, boxes, posts, painted lines; ray casting and contact tests
4
RobotParams, RobotModel
The physical robot: size, wheels, motor lag; wheel speed commands in, true pose and wheel angles out
5
Sensors
The range sensor, wheel encoders, line sensor and bump sensor, with noise, limits and delay
6
SimClock, Simulator
Time, and the three-method interface the robot code calls
7
Recorder, run, ascii_map
A CSV log of a run, the closed loop, a top-down picture
9
OdometryTracker
Dead reckoning from the ticks: the instrument for measuring drift
11
Conventions (so that every later chapter agrees): the arena is in metres, with x to the east (right) and y to the north (up). A pose is (x, y, θ) with θ in radians, counter-clockwise from the x axis, as in Robotics Fundamentals 4 section 5. In the robot’s own frame, forward is the direction it faces and left is 90 degrees counter-clockwise from it; a sensor is mounted at (forward, left, angle) in that frame. Times are seconds (floats) or whole microseconds inside the clock, and the unit is in the name (_m, _s, _rad, _mps), as in Programming for Robotics 1 section 8.
Who moves first. A simulated loop must say, once, in what order the world and the robot act, because the order sets the delay between a command and its effect. The simulator uses the order of the simulate function of Programming for Robotics 1 section 7: the world moves under the last command, then the robot senses, decides and acts.
time 0.00 s 0.02 s 0.04 s
| | |
world [ advance(0.02) ----->][ advance(0.02) ----->]
| |
robot update(0.02) update(0.02)
reads the world reads the world
at t = 0.02, at t = 0.04,
sets the command sets the command
for 0.02 ... 0.04 for 0.04 ... 0.06
3. Geometry first: rays, segments and circles
Everything a range sensor does comes down to one question: if I shoot a ray from a point in a direction, how far is it to the first thing it hits? The things are of two kinds: segments (walls, and the sides of boxes) and circles (posts). The ray is the set of points o + t d with t ≥ 0, where o is the origin and d a unit vector (length 1), so that t is a distance in metres.
A ray and a segment
The segment runs from p to p + e, the points p + u e with 0 ≤ u ≤ 1. The ray meets the line of the segment where the two expressions are equal. Write the 2D cross product a × b = ax by − ay bx, and q = p − o for the vector from the ray’s origin to the segment’s start:
o + t d = p + u e (two equations, two unknowns t and u)
t d - u e = q q = p - o
Cross both sides with e (e x e = 0 removes u): t (d x e) = q x e so t = (q x e) / (d x e)
Cross both sides with d (d x d = 0 removes t): -u (e x d) = q x d so u = (q x d) / (d x e)
A hit needs t >= 0 (in front of the origin) and 0 <= u <= 1 (inside the segment).
If d x e = 0 the ray is parallel to the segment: no single hit, so report none.
Worked example: o = (0.4, 0.3), d = (cos 30, sin 30) = (0.8660, 0.5), wall from p = (2.0, 0.0) to (2.0, 2.0), so e = (0, 2)
q = p - o = (1.6, -0.3)
d x e = 0.8660 x 2 - 0.5 x 0 = 1.7321
q x e = 1.6 x 2 - (-0.3) x 0 = 3.2 t = 3.2 / 1.7321 = 1.8475 m
q x d = 1.6 x 0.5 - (-0.3) x 0.8660 = 1.0598 u = 1.0598 / 1.7321 = 0.6119 hit at p + u e = (2.0, 1.2238)
check: o + t d = (0.4 + 1.8475 x 0.8660, 0.3 + 1.8475 x 0.5) = (2.0000, 1.2238)
y
^
2.0 * p + e = (2.0, 2.0)
|
| hit X (2.0, 1.2238) u = 0.612 of the way along e
| .-' |
| .-' |
| .-' t = 1.8475|m
0.3 | o ---' |
| (0.4, 0.3) d at 30 degrees
0.0 +----------------------- * p = (2.0, 0.0) ------> x
A ray and a circle
A point of the ray lies on the circle (centre c, radius r) when its distance from the centre is r. Put f = o − c, the vector from the centre to the ray’s origin. Because d has length 1, squaring the distance gives a quadratic in t:
| f + t d |^2 = r^2 expand, with d . d = 1: t^2 + 2 b t + c = 0
b = d . f c = f . f - r^2
t = -b -/+ sqrt(b^2 - c) the discriminant b^2 - c < 0 means the line misses the circle (the ray passes by)
The nearer crossing is t = -b - sqrt(b^2 - c); a hit needs t >= 0.
(If t < 0 because the origin is INSIDE the circle, or the circle is behind, report none: a sensor inside a post is a modelling mistake.)
Worked example: the same ray; post of radius 0.2 at c = (1.2, 0.9)
f = o - c = (-0.8, -0.6)
b = 0.8660 x (-0.8) + 0.5 x (-0.6) = -0.9928
c = 0.64 + 0.36 - 0.04 = 0.96
b^2 - c = 0.98569 - 0.96 = 0.02569 sqrt = 0.1603
t = 0.9928 - 0.1603 = 0.8325 m (the far crossing, t = 1.1531 m, is inside the post: the ray would leave there)
hit at (0.4 + 0.8325 x 0.8660, 0.3 + 0.8325 x 0.5) = (1.1210, 0.7163)
check: the distance of the hit from c = sqrt(0.0790^2 + 0.1837^2) = 0.2000. The ray passes the centre by sqrt(|f|^2 - b^2) = sqrt(1 - 0.9857) = 0.120 m, less than r = 0.2: a hit.
One more helper is needed for collisions: the point of a segment nearest to a given point. Project the point onto the segment’s line, u = (a − p) · e / (e · e), and clamp u to the range 0 to 1 so that the answer cannot leave the segment. The block below holds all four functions and the header of sim.py.
# sim.py -- the simulator from Programming for Robotics 3: a small 2D differential-drive robot world (standard library only)
# Hardware interface of Programming for Robotics 1 section 7: range_m(), ticks(), set_duty(left, right); plus line(), bump(), now_s(), advance(dt_s)
import math
import random
from collections import deque
def clamp(value, low, high):
return max(low, min(high, value))
def wrap_pi(angle):
"""Wrap an angle in radians to the range -pi .. +pi."""
return math.atan2(math.sin(angle), math.cos(angle))
def ray_segment(ox, oy, dx, dy, x1, y1, x2, y2):
"""Distance along the ray (origin ox, oy; UNIT direction dx, dy) to the segment, or None."""
ex, ey = x2 - x1, y2 - y1 # the segment's direction vector e
denom = dx * ey - dy * ex # cross(d, e); zero when the ray is parallel to the segment
if abs(denom) < 1e-12:
return None
qx, qy = x1 - ox, y1 - oy # q = from the ray origin to the segment start
t = (qx * ey - qy * ex) / denom # cross(q, e) / cross(d, e): distance along the ray
u = (qx * dy - qy * dx) / denom # cross(q, d) / cross(d, e): position along the segment, 0..1
if t >= 0.0 and 0.0 <= u <= 1.0:
return t
return None
def ray_circle(ox, oy, dx, dy, cx, cy, r):
"""Distance along the ray (UNIT direction) to the near side of the circle, or None."""
fx, fy = ox - cx, oy - cy # from the circle centre to the ray origin
b = dx * fx + dy * fy # d . f
c = fx * fx + fy * fy - r * r # |f|^2 - r^2
disc = b * b - c # the discriminant of t^2 + 2 b t + c = 0
if disc < 0.0:
return None # the line misses the circle
t = -b - math.sqrt(disc) # the nearer of the two crossings
return t if t >= 0.0 else None # behind the origin, or the origin is inside the circle
def closest_on_segment(px, py, x1, y1, x2, y2):
"""The point of the segment nearest to (px, py)."""
ex, ey = x2 - x1, y2 - y1
length_sq = ex * ex + ey * ey
u = 0.0 if length_sq == 0.0 else clamp(((px - x1) * ex + (py - y1) * ey) / length_sq, 0.0, 1.0)
return x1 + u * ex, y1 + u * ey
The numbers of the worked examples, and the cases that must return nothing:
a = math.radians(30)
dx, dy = math.cos(a), math.sin(a) # a unit direction: 30 degrees left of the x axis
ox, oy = 0.4, 0.3 # the ray starts here
t = ray_segment(ox, oy, dx, dy, 2.0, 0.0, 2.0, 2.0) # a wall along x = 2.0, from y = 0 to y = 2
print("wall x=2.0: t = %.4f m, hits at (%.4f, %.4f)" % (t, ox + t * dx, oy + t * dy))
t = ray_circle(ox, oy, dx, dy, 1.2, 0.9, 0.2) # a post of radius 0.2 centred at (1.2, 0.9)
print("post at (1.2,0.9): t = %.4f m, hits at (%.4f, %.4f)" % (t, ox + t * dx, oy + t * dy))
print("post at (1.2,1.4): ", ray_circle(ox, oy, dx, dy, 1.2, 1.4, 0.2))
print("parallel segment: ", ray_segment(ox, oy, 1.0, 0.0, 0.0, 1.0, 2.0, 1.0))
print("segment behind: ", ray_segment(ox, oy, -1.0, 0.0, 2.0, 0.0, 2.0, 2.0))
print("segment too short: ", ray_segment(ox, oy, dx, dy, 2.0, 0.0, 2.0, 1.0))
wall x=2.0: t = 1.8475 m, hits at (2.0000, 1.2238)
post at (1.2,0.9): t = 0.8325 m, hits at (1.1210, 0.7163)
post at (1.2,1.4): None
parallel segment: None
segment behind: None
segment too short: None
The first two lines are the hand calculations above. The other four are the cases that go wrong in real code: a post the ray passes by, a segment parallel to the ray, a segment behind the ray’s origin, and a segment that is too short to reach (the line through it is hit, the segment is not, because u is greater than 1).
4. The world: an arena, obstacles and contact
A World is a list of segments, a list of circles, and a list of painted lines. The four walls of the arena are four segments; a rectangular obstacle is four more; a post is a circle. raycast tests a ray against all of them and returns the nearest hit within the sensor’s maximum range. Every ray-shape test increments a counter, ray_tests, which section 12 uses to count the cost.
For collisions the robot’s body is a disc of radius R (the body_radius_m of the robot, 9 cm here). A disc overlaps a segment when its centre is nearer than R to the segment’s nearest point, and overlaps a circle of radius r when the centres are nearer than R + r. contact returns the deepest overlap as (nx, ny, depth): the normal, a unit vector pointing from the obstacle towards the robot, and how far the disc has sunk in. The normal tells the bumper where the contact is (section 6).
class World:
"""A rectangular arena with wall segments, circular obstacles and painted lines. Metres."""
def __init__(self, width_m, height_m):
self.width_m = width_m
self.height_m = height_m
w, h = width_m, height_m
self.segments = [(0, 0, w, 0), (w, 0, w, h), (w, h, 0, h), (0, h, 0, 0)] # the four arena walls
self.circles = [] # (cx, cy, r)
self.lines = [] # painted lines: (list of (x, y) points, width_m)
self.ray_tests = 0 # how many ray-shape tests were done (a cost counter)
def add_wall(self, x1, y1, x2, y2):
self.segments.append((x1, y1, x2, y2))
def add_box(self, x0, y0, x1, y1):
self.add_wall(x0, y0, x1, y0)
self.add_wall(x1, y0, x1, y1)
self.add_wall(x1, y1, x0, y1)
self.add_wall(x0, y1, x0, y0)
def add_circle(self, cx, cy, r):
self.circles.append((cx, cy, r))
def add_line(self, points, width_m=0.02):
self.lines.append((list(points), width_m))
def raycast(self, ox, oy, angle, max_range_m):
"""Distance from (ox, oy) along the angle to the nearest thing, or None if nothing within max_range_m."""
dx, dy = math.cos(angle), math.sin(angle)
best = None
for x1, y1, x2, y2 in self.segments:
self.ray_tests += 1
t = ray_segment(ox, oy, dx, dy, x1, y1, x2, y2)
if t is not None and (best is None or t < best):
best = t
for cx, cy, r in self.circles:
self.ray_tests += 1
t = ray_circle(ox, oy, dx, dy, cx, cy, r)
if t is not None and (best is None or t < best):
best = t
return best if (best is not None and best <= max_range_m) else None
def contact(self, x, y, radius_m):
"""Does a round robot body at (x, y) overlap anything? None, or (nx, ny, depth_m): the deepest overlap,
with (nx, ny) the unit vector pointing from the obstacle towards the robot."""
found = None
for x1, y1, x2, y2 in self.segments:
qx, qy = closest_on_segment(x, y, x1, y1, x2, y2)
d = math.hypot(x - qx, y - qy)
if 0.0 < d < radius_m and (found is None or radius_m - d > found[2]):
found = ((x - qx) / d, (y - qy) / d, radius_m - d)
for cx, cy, r in self.circles:
d = math.hypot(x - cx, y - cy)
if 0.0 < d < radius_m + r and (found is None or radius_m + r - d > found[2]):
found = ((x - cx) / d, (y - cy) / d, radius_m + r - d)
return found
def on_line(self, x, y):
"""Is the point on paint?"""
for points, width_m in self.lines:
for (x1, y1), (x2, y2) in zip(points, points[1:]):
qx, qy = closest_on_segment(x, y, x1, y1, x2, y2)
if math.hypot(x - qx, y - qy) <= width_m / 2:
return True
return False
An arena with a box and a post, a few rays, and a few contact tests (a body radius of 0.09 m):
world = World(3.0, 2.0) # a 3 m x 2 m arena (walls are added by the constructor)
world.add_box(1.9, 0.5, 2.2, 1.1) # a rectangular obstacle
world.add_circle(1.1, 1.4, 0.2) # a round post
for label, x, y, angle_deg in (("east, at the box", 0.3, 0.8, 0), ("north, at the wall", 0.3, 0.8, 90),
("north-east, at the post", 0.5, 0.9, 45), ("south, wall", 0.3, 0.8, -90),
("east, too far (max 1 m)", 0.3, 1.8, 0)):
r = world.raycast(x, y, math.radians(angle_deg), 1.0 if "max" in label else 4.0)
print("%-26s from (%.1f, %.1f): %s" % (label, x, y, "no echo" if r is None else "%.4f m" % r))
for label, x, y in (("clear of everything", 0.8, 0.8), ("against the south wall", 1.0, 0.06),
("touching the post", 1.1, 1.14), ("at the box corner", 1.85, 0.45)):
c = world.contact(x, y, 0.09)
print("%-24s at (%.2f, %.2f): %s" % (label, x, y, "free" if c is None else
"overlap %.1f mm, normal (%.2f, %.2f)" % (c[2] * 1000, c[0], c[1])))
east, at the box from (0.3, 0.8): 1.6000 m
north, at the wall from (0.3, 0.8): 1.2000 m
north-east, at the post from (0.5, 0.9): 0.5907 m
south, wall from (0.3, 0.8): 0.8000 m
east, too far (max 1 m) from (0.3, 1.8): no echo
clear of everything at (0.80, 0.80): free
against the south wall at (1.00, 0.06): overlap 30.0 mm, normal (0.00, 1.00)
touching the post at (1.10, 1.14): overlap 30.0 mm, normal (0.00, -1.00)
at the box corner at (1.85, 0.45): overlap 19.3 mm, normal (-0.71, -0.71)
The corner of the box is the instructive case: the nearest point of the box’s walls is the corner itself, so the normal points diagonally away from it, (−0.71, −0.71), the direction from the corner to the robot’s centre, and the overlap is the body radius minus the distance to the corner. The last ray test gives “no echo” because the maximum range (1 m) is shorter than the distance to the east wall (2.7 m): a real sensor times out the same way, and the robot code of Programming for Robotics 1 section 11 turns that into None.
5. The robot model: pose, wheels and a lazy motor
The model has to answer one question each physics step: given what the motors were told, where is the robot now, and how far has each wheel turned? Two things are needed, the way the wheels respond to a command, and the way two wheel motions move a body.
The unicycle model, exactly
Over one step the two wheels roll distances ΔdL and ΔdR along the ground. The centre of the robot travels the average and the heading changes by the difference over the track width L (Robotics Fundamentals 4 section 3). If the wheel speeds are constant over the step, the robot moves along an arc, a piece of a circle about the instantaneous centre of curvature (ICC), and the exact new pose follows by rotating the old position about the ICC:
ds = (dR + dL) / 2 dtheta = (dR - dL) / L R = ds / dtheta (the radius of the arc)
The ICC is a distance R to the LEFT of the robot: ICC = (x - R sin(theta), y + R cos(theta))
Rotate the robot's position about it by dtheta: x' = ICC_x + R sin(theta + dtheta) = x + R ( sin(theta + dtheta) - sin(theta) )
y' = ICC_y - R cos(theta + dtheta) = y - R ( cos(theta + dtheta) - cos(theta) )
theta' = theta + dtheta
When dtheta is tiny (straight driving) R is huge: use the limit x' = x + ds cos(theta), y' = y + ds sin(theta).
Two cheaper formulas, and what they cost:
euler: x' = x + ds cos(theta), y' = y + ds sin(theta) the OLD heading for the whole step
midpoint: x' = x + ds cos(theta + dtheta/2), y' = ... the heading half way through the step
How they relate: R ( sin(theta + dtheta) - sin(theta) ) = 2 R sin(dtheta/2) cos(theta + dtheta/2) = ds x [ sin(dtheta/2) / (dtheta/2) ] x cos(theta + dtheta/2)
so the arc update IS the midpoint update times the chord factor sin(dtheta/2) / (dtheta/2) = 1 - dtheta^2/24 + ... (0.9933 for dtheta = 0.4 rad: the chord is 0.7 % shorter than the arc)
and for a run of equal steps the euler endpoint is the exact endpoint turned back by half a step of heading: error = chord x dtheta_step / 2.
The motor: a first-order lag
A command to a real motor is not obeyed instantly: the wheel speeds up over a fraction of a second. The simplest model that captures this is a first-order lag: the wheel speed ω moves towards the commanded speed ωc at a rate proportional to the gap, with a time constantτ. The model integrates the angle exactly, in closed form, so the result does not depend on how long the step is:
d(omega)/dt = (omega_c - omega) / tau for a constant command omega_c over the step of length h:
omega(t) = omega_c + (omega_0 - omega_c) e^(-t/tau) the speed at the end of the step
angle turned over the step = integral of omega = omega_c h + (omega_0 - omega_c) tau ( 1 - e^(-h/tau) )
From standstill (omega_0 = 0), a step command: omega(t) = omega_c ( 1 - e^(-t/tau) )
after tau: 63.2 % of the final speed after 3 tau: 95.0 %
Distance for a step command of speed v held for time T: v ( T - tau ( 1 - e^(-T/tau) ) ) which is v T - v tau once T is several tau
so the lag costs the robot v tau metres: 0.40 x 0.08 = 3.2 cm at full speed.
The time constant is measured, not guessed: command a step on the real robot, log the encoder speed, and find the time it takes to reach 63 %.
The model also carries a scale for each wheel, scale_left and scale_right: the ground distance the wheel really rolls per radian it turns, relative to what the software believes. A value of 1.01 on the right wheel is a tyre that is 1 % too big (Robotics Fundamentals 4 section 6, worked example 1); 0.98 is a wheel that loses 2 % of its distance to slip. The encoders count the wheel’s angle, which does not know about the scale, which is exactly why odometry drifts. The robot’s commands are speeds in m/s, converted to wheel angular speeds with the nominal radius, like the software does it on a real robot.
def advance_pose(x, y, theta, ds, dtheta, method="arc"):
"""Move a pose by the centre travel ds and the turn dtheta (the unicycle model)."""
if method == "euler": # the crude one: the OLD heading for the whole step
x += ds * math.cos(theta)
y += ds * math.sin(theta)
elif method == "midpoint" or abs(dtheta) < 1e-9:
x += ds * math.cos(theta + dtheta / 2) # the heading half way through (also exact for a straight line)
y += ds * math.sin(theta + dtheta / 2)
else: # "arc": exact for constant wheel speeds
radius = ds / dtheta
x += radius * (math.sin(theta + dtheta) - math.sin(theta))
y -= radius * (math.cos(theta + dtheta) - math.cos(theta))
return x, y, wrap_pi(theta + dtheta)
class RobotParams:
"""The physical robot. The defaults are the build robot of Robotics Fundamentals 10 section 9."""
def __init__(self, wheel_radius_m=0.0325, track_width_m=0.150, ticks_per_rev=600,
max_speed_mps=0.40, tau_s=0.08, body_radius_m=0.09, scale_left=1.0, scale_right=1.0):
self.wheel_radius_m = wheel_radius_m # the radius the SOFTWARE believes
self.track_width_m = track_width_m
self.ticks_per_rev = ticks_per_rev # encoder counts per wheel revolution
self.max_speed_mps = max_speed_mps # wheel speed at duty 1.0
self.tau_s = tau_s # motor time constant: 0 means an instant response
self.body_radius_m = body_radius_m # the robot is a disc of this radius
self.scale_left = scale_left # ground distance per wheel radian, relative to the belief:
self.scale_right = scale_right # 1.01 = the tyre is really 1 % bigger; 0.98 = 2 % slip
class RobotModel:
"""True pose, true wheel motion. Wheel speed COMMANDS go in; true wheel angles and the pose come out."""
def __init__(self, params, x=0.0, y=0.0, theta=0.0, integrator="arc"):
self.p = params
self.x, self.y, self.theta = x, y, theta
self.integrator = integrator
self.cmd_w = [0.0, 0.0] # commanded wheel speeds, rad/s
self.w = [0.0, 0.0] # true wheel speeds, rad/s
self.angle = [0.0, 0.0] # total angle each wheel has turned, rad
def set_wheel_speeds(self, left_mps, right_mps):
p = self.p
limit = p.max_speed_mps
self.cmd_w = [clamp(left_mps, -limit, limit) / p.wheel_radius_m,
clamp(right_mps, -limit, limit) / p.wheel_radius_m]
def true_speeds_mps(self):
p = self.p
return (self.w[0] * p.wheel_radius_m * p.scale_left, self.w[1] * p.wheel_radius_m * p.scale_right)
def step(self, dt_s):
p = self.p
scale = (p.scale_left, p.scale_right)
dist = [0.0, 0.0]
for i in (0, 1):
w0, wc = self.w[i], self.cmd_w[i]
if p.tau_s > 0.0: # first-order lag: dw/dt = (wc - w) / tau, solved exactly
decay = math.exp(-dt_s / p.tau_s)
turned = wc * dt_s + (w0 - wc) * p.tau_s * (1.0 - decay) # the integral of w over the step
self.w[i] = wc + (w0 - wc) * decay
else:
turned = wc * dt_s
self.w[i] = wc
self.angle[i] += turned
dist[i] = turned * p.wheel_radius_m * scale[i] # metres rolled along the ground
ds = 0.5 * (dist[0] + dist[1])
dtheta = (dist[1] - dist[0]) / p.track_width_m
self.x, self.y, self.theta = advance_pose(self.x, self.y, self.theta, ds, dtheta, self.integrator)
def snapshot(self):
return (self.x, self.y, self.theta, list(self.angle))
def restore(self, snap):
self.x, self.y, self.theta = snap[0], snap[1], snap[2]
self.angle = list(snap[3])
self.w = [0.0, 0.0] # the wheels were stopped by the obstacle
First the motor lag, against its formula. The model is given full speed from standstill and stepped for 0.5 s in passes of 0.02 s:
params = RobotParams() # tau = 0.08 s
model = RobotModel(params)
model.set_wheel_speeds(0.40, 0.40) # a step command: full speed from standstill
print(" t (s) true v (m/s) formula 0.40 (1 - e^(-t/tau)) distance (m)")
for k in range(1, 26):
model.step(0.02)
if k in (1, 4, 10, 25):
t = 0.02 * k
vl, vr = model.true_speeds_mps()
exact = 0.40 * (1 - math.exp(-t / params.tau_s))
print(" %.2f %.4f %.4f %.4f" % (t, vl, exact, model.x))
print("one time constant (0.08 s) gives %.1f %% of the final speed" % (100 * (1 - math.exp(-1))))
ideal = 0.40 * 0.5
lag = 0.40 * (0.5 - params.tau_s * (1 - math.exp(-0.5 / params.tau_s)))
print("distance in 0.5 s: ideal %.4f m, with lag %.4f m, simulated %.4f m" % (ideal, lag, model.x))
t (s) true v (m/s) formula 0.40 (1 - e^(-t/tau)) distance (m)
0.02 0.0885 0.0885 0.0009
0.08 0.2528 0.2528 0.0118
0.20 0.3672 0.3672 0.0506
0.50 0.3992 0.3992 0.1681
one time constant (0.08 s) gives 63.2 % of the final speed
distance in 0.5 s: ideal 0.2000 m, with lag 0.1681 m, simulated 0.1681 m
The simulated speed and the formula agree to every digit shown, because the model uses the closed form. One time constant gives 63.2 %, and 2.5 time constants (0.20 s) give 91.8 %. The lag costs 3.2 cm at full speed: after 0.5 s the robot has gone 0.1681 m instead of the ideal 0.2000 m.
Now the three pose integrators, on a robot with no lag so that the wheel speeds are exactly constant and the answer is known. The wheels run at 0.20 and 0.30 m/s for 2.4 s, a turn of 1.6 rad along an arc of radius 0.375 m, and the exact answer comes from one advance_pose call over the whole motion. Then the same motion is integrated in 4, 8, 24 and 120 steps:
fast = RobotParams(tau_s=0.0) # no lag: the wheel speeds are exactly the commands
left, right, total_s = 0.20, 0.30, 2.4 # constant wheel speeds for 2.4 s
ds = 0.5 * (left + right) * total_s # 1.2 m travelled by the centre
dtheta = (right - left) / fast.track_width_m * total_s # 1.6 rad of turning
ex, ey, eth = advance_pose(0.0, 0.0, 0.0, ds, dtheta, "arc") # the whole motion as ONE exact arc
print("exact: x = %.4f m, y = %.4f m, heading = %.1f deg (R = %.3f m)" % (ex, ey, math.degrees(eth), ds / dtheta))
print("dt (s) steps error of euler midpoint arc")
for dt in (0.6, 0.3, 0.1, 0.02):
errors = []
for method in ("euler", "midpoint", "arc"):
model = RobotModel(fast, integrator=method)
model.set_wheel_speeds(left, right)
steps = int(round(total_s / dt))
for _ in range(steps):
model.step(dt)
errors.append(1000 * math.hypot(model.x - ex, model.y - ey))
print("%-7s %-6d %8.1f mm %8.2f mm %8.4f mm" % (dt, steps, errors[0], errors[1], errors[2]))
exact: x = 0.3748 m, y = 0.3859 m, heading = 91.7 deg (R = 0.375 m)
dt (s) steps error of euler midpoint arc
0.6 4 107.8 mm 3.60 mm 0.0000 mm
0.3 8 53.8 mm 0.90 mm 0.0000 mm
0.1 24 17.9 mm 0.10 mm 0.0000 mm
0.02 120 3.6 mm 0.00 mm 0.0000 mm
The error of euler falls in proportion to the step (17.9 mm at 0.1 s, 3.6 mm at 0.02 s), and it matches the formula above: the chord is 2 × 0.375 × sin(0.8) = 0.538 m, and with 4 steps each turning 0.4 rad the error is 0.538 × 0.4 / 2 = 0.1076 m, close to the 107.8 mm shown. Even at 120 steps it is still 3.6 mm, in a 1.2 m run. Midpoint falls with the square of the step (3.60 mm, then 0.90 mm when the step is halved), as the chord factor says. The arc update is exact for any step, because constant wheel speeds are an arc. That is why sim.py uses it by default, and why the Euler option is there: to show you what the cheap formula costs. (With the motor lag the wheel speeds are not constant, but both wheels follow the same lag curve from rest, so a run that starts from standstill with constant commands is still a perfect arc. With changing commands the arc update has a small second-order error too, which the physics step of section 7 keeps tiny.)
6. Simulated sensors
A sensor is a model of what comes out of the part, not of the physics inside it. The same recipe fits every one: take the true value, turn it into what the part would report (limits, noise, whole counts, delay), and give back None where the part would fail to answer.
The range sensor
An ultrasonic or infrared sensor (Robotics Fundamentals 5 section 3), as a ray from a mount point on the robot (forward, left, angle): the true range is the ray cast, then Gaussian noise of standard deviation σ, then the limits: nothing within the maximum range is a timeout (None), and anything under the minimum range is the blind zone (also None). Three options make it realistic:
A cone (fov_rad, n_rays). A real ultrasonic beam is wide (about 15 degrees each side, Robotics Fundamentals 5 section 3), and it reports the nearest thing in it. The model casts n_rays rays across the cone and takes the shortest. Section 12 shows what too few rays do.
A rate (period_s). The sensor measures every period_s and holds the last value in between, like a ping every 25 ms.
A delay (latency_s). A reading is delivered latency_s after it was measured, through a queue. This is the option for the latency chain of Programming for Robotics 1 section 12; section 13 shows what it does to a controller.
The noise assumption deserves a caution. Gaussian noise is a good first model of the small random jitter of a reading, and it is the kind that averaging removes (the standard error σ/√N, Robotics Fundamentals 5 section 9). It is a poor model of the outliers of a real ultrasonic sensor: an echo from the wrong wall, a missed pulse. The simulator’s noise is therefore the easy case, and a filter that only passes the simulator’s noise has not been tested against the hard one.
The encoders
An encoder counts whole ticks of the wheel’s angle: ticks = floor(angle / 2π × N) with N = 600 ticks per wheel revolution. The error of the count is the fraction of a tick that was cut off. If the wheel is at a random position, that fraction is uniformly spread over 0 to 1, so the error e = ticks − exact has mean −1/2 and variance 1/12 (the variance of a uniform distribution of width 1), a standard deviation of 1/√12 = 0.2887 tick. At 0.34034 mm per tick (Robotics Fundamentals 4 section 5) that is 0.098 mm: tiny for a distance. It matters in a speed computed from the difference of two counts over one short pass (section 8), and Robotics Fundamentals 6 section 2 treats it as the quantisation error of speed. The class also takes an optional counting scale and bias (a lost count, a wrong zero), which the drift experiments of section 11 can use.
The line sensor and the bump sensor
The line sensor (Robotics Fundamentals 5 section 4) is a row of n points across the front of the robot; each reads 1 if its point is over paint, which is a line of given width, the distance of the point from the polyline being at most half the width. Index 0 is the leftmost sensor. The bump sensor (Robotics Fundamentals 5 section 5) is a bumper over an arc of the body: pressed when the contact point lies inside the arc. The contact point is on the side of the robot opposite to the contact normal, so its bearing relative to the heading is the angle of (−nx, −ny) minus θ. The default arc is the front half (180 degrees).
class RangeSensor:
"""An ultrasonic or infrared style range sensor: rays cast into the world, noise, limits, rate and delay."""
def __init__(self, world, rng, mount=(0.07, 0.0, 0.0), max_range_m=4.0, min_range_m=0.02, sigma_m=0.005,
fov_rad=0.0, n_rays=1, period_s=0.0, latency_s=0.0):
self.world = world
self.rng = rng # this sensor's own random.Random: no shared global state
self.mount = mount # (forward, left, angle) in the robot's frame
self.max_range_m = max_range_m
self.min_range_m = min_range_m
self.sigma_m = sigma_m # standard deviation of the Gaussian noise
self.fov_rad = fov_rad # width of the cone; with n_rays rays spread across it
self.n_rays = n_rays
self.period_s = period_s # 0 = measure on every update; 0.025 = 40 Hz
self.latency_s = latency_s # age of the reading when you read it
self._next_t = 0.0
self._queue = deque() # (time measured, reading), oldest first
self._held = None # the newest reading that is old enough to be delivered
def true_range(self, pose):
x, y, theta = pose
fwd, left, mount_angle = self.mount
ox = x + fwd * math.cos(theta) - left * math.sin(theta)
oy = y + fwd * math.sin(theta) + left * math.cos(theta)
best = None
for k in range(self.n_rays):
offset = 0.0 if self.n_rays == 1 else -self.fov_rad / 2 + self.fov_rad * k / (self.n_rays - 1)
r = self.world.raycast(ox, oy, theta + mount_angle + offset, self.max_range_m)
if r is not None and (best is None or r < best):
best = r
return best
def _measure(self, pose):
noise = self.rng.gauss(0.0, self.sigma_m) # always draw one number: the random stream never depends on the geometry
r = self.true_range(pose)
if r is None:
return None # nothing in range: a timeout
r += noise
if r < self.min_range_m or r > self.max_range_m:
return None
return r
def update(self, pose, now_s):
if now_s + 1e-9 < self._next_t:
return # not time for a new measurement yet
self._next_t = now_s + self.period_s
self._queue.append((now_s, self._measure(pose)))
def read(self, now_s):
while self._queue and self._queue[0][0] <= now_s - self.latency_s + 1e-9:
self._held = self._queue.popleft()[1]
return self._held
class Encoders:
"""Two wheel encoders: whole counts of the wheel angle, with an optional counting scale error and a bias."""
def __init__(self, ticks_per_rev, scale=(1.0, 1.0), bias=(0, 0)):
self.ticks_per_rev = ticks_per_rev
self.scale = scale
self.bias = bias
def read(self, angles_rad):
return tuple(int(math.floor(a / (2 * math.pi) * self.ticks_per_rev * s)) + b
for a, s, b in zip(angles_rad, self.scale, self.bias))
class LineSensor:
"""A row of n reflectance sensors across the front of the robot (index 0 = leftmost); 1 = over the line."""
def __init__(self, world, n=5, spacing_m=0.015, forward_m=0.07):
self.world = world
self.offsets = [((n - 1) / 2 - k) * spacing_m for k in range(n)]
self.forward_m = forward_m
def read(self, pose):
x, y, theta = pose
out = []
for left in self.offsets:
px = x + self.forward_m * math.cos(theta) - left * math.sin(theta)
py = y + self.forward_m * math.sin(theta) + left * math.cos(theta)
out.append(1 if self.world.on_line(px, py) else 0)
return tuple(out)
class BumpSensor:
"""A bumper over the front arc of the body: pressed when the contact point is inside the arc."""
def __init__(self, arc_rad=math.pi):
self.arc_rad = arc_rad
def pressed(self, contact, theta):
if contact is None:
return False
nx, ny, _ = contact # the normal points from the obstacle to the robot,
bearing = wrap_pi(math.atan2(-ny, -nx) - theta) # so the contact point is the opposite way
return abs(bearing) <= self.arc_rad / 2
First the range sensor against the statistics. A robot 1 m up in a 3 m by 2 m arena faces the east wall; 1000 readings at 50 Hz, a sigma of 5 mm, then the two limits, then the encoder error over 100 000 random wheel positions:
arena = World(3.0, 2.0) # a plain arena: the wall ahead is at x = 3.0
pose = (0.3, 1.0, 0.0) # the robot, 1.0 m up, facing east
sensor = RangeSensor(arena, random.Random(7), sigma_m=0.005) # the sensor is mounted 0.07 m ahead of the centre
n = 1000
readings = []
for i in range(n):
sensor.update(pose, i * 0.02)
readings.append(sensor.read(i * 0.02))
mean = sum(readings) / n
std = math.sqrt(sum((r - mean) ** 2 for r in readings) / (n - 1))
inside = sum(1 for r in readings if abs(r - sensor.true_range(pose)) <= 2 * 0.005) / n
print("true range %.3f m; %d readings: mean %.4f m, std %.4f m (sigma 0.0050), within 2 sigma: %.1f %%"
% (sensor.true_range(pose), n, mean, std, 100 * inside))
print("standard error of the mean = sigma / sqrt(n) = %.5f m; the mean is %.5f m from the truth"
% (0.005 / math.sqrt(n), abs(mean - sensor.true_range(pose))))
short = RangeSensor(arena, random.Random(7), max_range_m=2.0) # a sensor that reaches only 2 m
near = RangeSensor(arena, random.Random(7), min_range_m=0.10) # a sensor with a 10 cm blind zone
for label, s, p in (("max range 2 m, wall 2.63 m away", short, pose), ("blind zone 0.10 m, wall 0.05 m away", near, (2.88, 1.0, 0.0))):
s.update(p, 0.0)
print("%-38s -> %s" % (label, s.read(0.0)))
# encoders: whole counts, so the error is the fraction of a tick that was cut off
enc = Encoders(600)
rng = random.Random(3)
errors = []
for _ in range(100000):
angle = rng.uniform(0.0, 20 * 2 * math.pi) # a wheel angle somewhere in 20 turns
exact_ticks = angle / (2 * math.pi) * 600
errors.append(enc.read((angle, angle))[0] - exact_ticks)
mean = sum(errors) / len(errors)
std = math.sqrt(sum((e - mean) ** 2 for e in errors) / len(errors))
mpt = 2 * math.pi * 0.0325 / 600
print("encoder error: mean %.3f tick (theory -0.5), std %.4f tick (theory 1/sqrt(12) = %.4f) = %.4f mm"
% (mean, std, 1 / math.sqrt(12), std * mpt * 1000))
true range 2.630 m; 1000 readings: mean 2.6301 m, std 0.0050 m (sigma 0.0050), within 2 sigma: 95.1 %
standard error of the mean = sigma / sqrt(n) = 0.00016 m; the mean is 0.00014 m from the truth
max range 2 m, wall 2.63 m away -> None
blind zone 0.10 m, wall 0.05 m away -> None
encoder error: mean -0.500 tick (theory -0.5), std 0.2888 tick (theory 1/sqrt(12) = 0.2887) = 0.0983 mm
The mean is within 0.14 mm of the truth, inside the 0.16 mm that the standard error predicts, the spread is the 5 mm that was asked for, and 95.1 % of the readings fall inside two sigma (a Gaussian puts 95.4 % there). The encoder error has the mean −0.500 tick and the spread 0.2888 tick of the uniform-fraction argument, the 1/√12 = 0.2887 tick above. In other words the simulator behaves as the algebra said it would; that is the check that the code is right.
The line sensor and the bump sensor next. The robot crosses a painted line at several offsets, and a wall touches the bumper from several bearings:
painted = World(3.0, 2.0)
painted.add_line([(0.0, 1.0), (3.0, 1.0)], width_m=0.02) # a 2 cm line along y = 1.0
ls = LineSensor(painted) # five sensors, 15 mm apart, index 0 = left
print("robot y - line y sensors (left ... right)")
for offset in (0.0, 0.015, -0.015, 0.03, -0.03, 0.12):
print(" %+.3f m %s" % (offset, ls.read((1.0, 1.0 + offset, 0.0))))
bumper = BumpSensor(arc_rad=math.radians(180)) # a bumper over the front half
for bearing_deg in (0, 80, -80, 100, 180): # where the wall touches, relative to the heading
theta = 0.3 # (any heading will do)
wall_dir = theta + math.radians(bearing_deg) # the direction from the robot to the contact point
contact = (-math.cos(wall_dir), -math.sin(wall_dir), 0.003) # the normal points from the wall to the robot
print("contact at bearing %+4d deg: pressed = %s" % (bearing_deg, bumper.pressed(contact, theta)))
robot y - line y sensors (left ... right)
+0.000 m (0, 0, 1, 0, 0)
+0.015 m (0, 0, 0, 1, 0)
-0.015 m (0, 1, 0, 0, 0)
+0.030 m (0, 0, 0, 0, 1)
-0.030 m (1, 0, 0, 0, 0)
+0.120 m (0, 0, 0, 0, 0)
contact at bearing +0 deg: pressed = True
contact at bearing +80 deg: pressed = True
contact at bearing -80 deg: pressed = True
contact at bearing +100 deg: pressed = False
contact at bearing +180 deg: pressed = False
The line sensor sees the paint through the one sensor that is over it, and the 1 moves left or right as the robot moves off the line, and sees nothing beyond about 4 cm. The bumper is pressed for contacts up to 80 degrees from the heading either side, and silent for 100 and 180 degrees: it covers the front half only. Programming for Robotics 4 turns these raw readings into a position estimate.
7. The clock and the Simulator: one object with the robot’s three methods
SimClock is the simulator’s own time. It keeps the time in whole microseconds (an integer) and exposes now_s() like time.monotonic(), in the way FakeClock of Programming for Robotics 1 section 4 did, so adding up 50 passes of 0.02 s gives exactly 1.000000 s, not 0.9999999999999999. Simulator ties everything together:
The interface.range_m(), ticks() and set_duty(left, right) are the three methods of Programming for Robotics 1 section 7, with the same meaning (a distance in metres or None; two whole counts; duty cycles from −1 to +1, where 1.0 is the maximum wheel speed). line() is the method PicoHardware of Programming for Robotics 2 section 10 also has. The extras, bump(), range_of(name), now_s() and advance(dt_s), are for the world and the tests.
advance(dt_s) moves the world. It is the advance that the fakes of Programming for Robotics 1 had, so the simulate function of that chapter works on a Simulator unchanged. The control pass is 0.02 s but the physics runs in smaller steps (physics_dt_s, 5 ms by default, so 4 physics steps per pass), because a coarse step is less accurate and can step through a thin wall (section 12). After the physics, every sensor takes one new reading.
Collision. After each physics step the body is tested against the world. If it overlaps something, the step is undone (pose and wheel angles restored), the wheels are stopped, and the contact is recorded. The robot stops where it touches, within one physics step of travel (2 mm at 0.4 m/s, 5 ms), and because the wheel angles are restored, the encoders stop counting, as the wheels of a robot pushing against a wall stall. The motors are still being commanded, so a robot that keeps pushing keeps registering the contact on every pass.
Seeds.Simulator(seed=...) creates one random.Random(seed) and gives every sensor its own stream from it.
class SimClock:
"""Simulated time in whole microseconds: exact, deterministic and independent of the PC's speed."""
def __init__(self):
self.us = 0
def now_s(self): # seconds, like time.monotonic()
return self.us / 1000000
def advance(self, dt_s):
self.us += int(round(dt_s * 1000000))
class Simulator:
"""The hardware interface of Programming for Robotics 1 section 7 on a simulated world:
range_m(), ticks(), set_duty(left, right). Also line(), bump(), now_s() and advance(dt_s)."""
def __init__(self, world, params=None, seed=1, pose=(0.3, 0.3, 0.0), physics_dt_s=0.005,
integrator="arc", **range_kwargs):
self.world = world
self.params = params or RobotParams()
self.rng = random.Random(seed) # the one source of randomness; each sensor gets its own stream from it
self.clock = SimClock()
self.model = RobotModel(self.params, pose[0], pose[1], pose[2], integrator)
self.physics_dt_s = physics_dt_s
self.range_sensors = {}
self.add_range_sensor("front", **range_kwargs)
self.encoders = Encoders(self.params.ticks_per_rev)
self.line_sensor = LineSensor(world)
self.bumper = BumpSensor()
self.contact = None # the contact found during the last advance(), if any
self.bumps = 0 # how many advance() calls ended in a collision
for sensor in self.range_sensors.values():
sensor.update(self.pose(), self.clock.now_s())
def add_range_sensor(self, name, **kwargs):
sensor = RangeSensor(self.world, random.Random(self.rng.getrandbits(32)), **kwargs)
self.range_sensors[name] = sensor
return sensor
# ---- the hardware interface ----
def range_m(self):
return self.range_sensors["front"].read(self.clock.now_s())
def range_of(self, name):
return self.range_sensors[name].read(self.clock.now_s())
def ticks(self):
return self.encoders.read(self.model.angle)
def set_duty(self, left, right):
limit = self.params.max_speed_mps
self.model.set_wheel_speeds(clamp(left, -1.0, 1.0) * limit, clamp(right, -1.0, 1.0) * limit)
def line(self):
return self.line_sensor.read(self.pose())
def bump(self):
return self.bumper.pressed(self.contact, self.model.theta)
# ---- the world moves on ----
def now_s(self):
return self.clock.now_s()
def pose(self):
return (self.model.x, self.model.y, self.model.theta)
def advance(self, dt_s):
"""Move the world forward by dt_s: physics in small steps, then one new reading from every sensor."""
n = max(1, int(math.ceil(dt_s / self.physics_dt_s - 1e-9)))
h = dt_s / n
self.contact = None
for _ in range(n):
before = self.model.snapshot()
self.model.step(h)
contact = self.world.contact(self.model.x, self.model.y, self.params.body_radius_m)
if contact is not None: # collision: undo this step, stop the wheels, remember where it touched
self.model.restore(before)
self.contact = contact
if self.contact is not None:
self.bumps += 1
self.clock.advance(dt_s)
for sensor in self.range_sensors.values():
sensor.update(self.pose(), self.clock.now_s())
The first thing to try is the simplest: the robot code is not involved at all. Command duty 0.5 on both wheels for 2 s with set_duty and compare the distance to the formula of section 5:
sim = Simulator(arena, seed=1, pose=(0.3, 1.0, 0.0))
# 1. open loop: duty 0.5 on both wheels for 2 s, the robot code not involved
sim.set_duty(0.5, 0.5)
for _ in range(100):
sim.advance(0.02)
tau, v = sim.params.tau_s, 0.5 * sim.params.max_speed_mps
expected = v * (2.0 - tau * (1 - math.exp(-2.0 / tau)))
print("open loop, 2 s at duty 0.5: x = %.4f m (formula %.4f m, ideal %.4f m); t = %.2f s; ticks %s"
% (sim.pose()[0] - 0.3, expected, v * 2.0, sim.now_s(), sim.ticks()))
print("ticks expected: %.1f per wheel" % (expected / (2 * math.pi * 0.0325 / 600)))
print("the interface of Programming for Robotics 1: range_m() = %.3f m, line() = %s, bump() = %s"
% (sim.range_m(), sim.line(), sim.bump()))
same = all(callable(getattr(sim, m)) and callable(getattr(WallWorld, m)) for m in ("range_m", "ticks", "set_duty", "advance"))
print("the methods the Chapter 1 fakes have (range_m, ticks, set_duty, advance) are all on the Simulator too: %s" % same)
open loop, 2 s at duty 0.5: x = 0.3840 m (formula 0.3840 m, ideal 0.4000 m); t = 2.00 s; ticks (1128, 1128)
ticks expected: 1128.3 per wheel
the interface of Programming for Robotics 1: range_m() = 2.245 m, line() = (0, 0, 0, 0, 0), bump() = False
the methods the Chapter 1 fakes have (range_m, ticks, set_duty, advance) are all on the Simulator too: True
The robot moved 0.3840 m, not the 0.4000 m of 0.20 m/s times 2 s: the lag costs 0.20 × 0.08 = 1.6 cm. The two wheels counted 1128 ticks (the formula says 1128.3, rounded down to a whole tick), and the sensor-side calls, range_m(), line() and bump(), answer like those of a hardware class, which is what the last line confirms: the three-method interface is a subset of the WallWorld of Programming for Robotics 1, so everything written against a fake runs here.
8. Scenarios: driving straight, a wall, a collision
A scenario is a simulator, a robot, and a loop. The first is the one that matters: the Robot of Programming for Robotics 1 section 6, unchanged, driving at a wall. The wall is 3.0 m from the arena’s west wall, the robot starts at x = 0.3 m with its range sensor 0.07 m ahead of its centre, and the rule is the Programming for Robotics 1 one: cruise at 0.30 m/s, slow down from 0.60 m and stop at 0.25 m. The loop is two lines, in the order of section 2:
sim = Simulator(arena, seed=1, pose=(0.3, 1.0, 0.0))
robot = Robot(sim) # the Robot of Programming for Robotics 1, UNCHANGED
print(" t (s) x (m) range read (m) speed (m/s) command (m/s)")
cruise_speeds = set()
for step in range(1, 701):
sim.advance(0.02) # the world moves under the last command ...
robot.update(0.02) # ... then the robot senses, decides and acts
if 100 <= step <= 300:
cruise_speeds.add(round(robot.state.speed_mps, 3)) # the speed the robot sees while cruising
if step % 100 == 0:
print(" %.1f %.3f %.3f %.3f %.3f"
% (sim.now_s(), sim.pose()[0], robot.state.range_m, robot.state.speed_mps, robot.state.cmd_left_mps))
x = sim.pose()[0]
true_range = sim.range_sensors["front"].true_range(sim.pose())
print("final: true range %.3f m (stop line 0.250), body gap to the wall %.3f m, bumps %d, fault: %s"
% (true_range, 3.0 - (x + sim.params.body_radius_m), sim.bumps, robot.state.fault))
print("speeds seen while cruising (passes 100 to 300): %s m/s, command 0.300; ticks per pass at 0.3 m/s = %.1f" % (sorted(cruise_speeds), 0.3 * 0.02 / (2 * math.pi * 0.0325 / 600)))
t (s) x (m) range read (m) speed (m/s) command (m/s)
2.0 0.870 2.068 0.289 0.300
4.0 1.470 1.465 0.289 0.300
6.0 2.070 0.865 0.289 0.300
8.0 2.561 0.372 0.119 0.105
10.0 2.662 0.267 0.017 0.015
12.0 2.677 0.249 0.000 0.000
14.0 2.680 0.252 0.000 0.001
final: true range 0.250 m (stop line 0.250), body gap to the wall 0.230 m, bumps 0, fault: None
speeds seen while cruising (passes 100 to 300): [0.289, 0.306] m/s, command 0.300; ticks per pass at 0.3 m/s = 17.6
This is the result the whole chapter has been working towards: the code of Programming for Robotics 1 section 6 ran on a world with a lagging motor, noisy readings and whole-tick encoders, and stopped at the stop line. The true range is 0.250 m (the 0.250 m stop line), the body is 23 cm from the wall and nothing was bumped. Two details are worth a look. The speed from the encoders is 0.289 m/s in the lines above, and the last line shows that it is not constant: it takes only the values 0.289 and 0.306 m/s while the command is 0.300: at that speed the robot moves 17.6 ticks per pass, the count is a whole number (17 or 18), and one tick per pass is 0.00034034 / 0.02 = 0.017 m/s. That is the quantisation of section 6, now visible; Programming for Robotics 4 filters it. And the approach is slow at the end: the proportional rule shrinks the speed as the range shrinks, so the last few centimetres take seconds (the gap closes with a time constant of 1 / 0.857 = 1.2 s).
The second scenario is the one the simulator exists to make safe: a collision. The robot starts 1.0 m from the wall and is told to drive at full speed, with no stopping rule at all:
sim = Simulator(arena, seed=1, pose=(2.0, 1.0, 0.0)) # 1.0 m from the wall: full speed will hit it
sim.set_duty(1.0, 1.0)
first = None
for step in range(1, 301):
sim.advance(0.02)
if sim.bump() and first is None:
first = (sim.now_s(), sim.pose()[0], sim.model.true_speeds_mps()[0])
print("first bump at t = %.2f s, x = %.4f m (body gap %.1f mm), true wheel speed now %.3f m/s"
% (first[0], first[1], (3.0 - first[1] - sim.params.body_radius_m) * 1000, first[2]))
x_at_bump = sim.pose()[0]
x_end = sim.pose()[0]
print("after 6 s of pushing: x = %.4f m (moved %.4f m since the bump), bump() = %s, bumps = %d of %d passes"
% (x_end, x_end - x_at_bump, sim.bump(), sim.bumps, 300))
print("gap between the body and the wall now: %.3f mm (one 5 ms physics step at 0.4 m/s is 2 mm)"
% ((3.0 - x_end - sim.params.body_radius_m) * 1000))
print("the encoders did not count the pushing: ticks %s, odometry would say %.4f m, the robot moved %.4f m"
% (sim.ticks(), sim.ticks()[0] * 2 * math.pi * 0.0325 / 600, x_end - 2.0))
sim.set_duty(-0.5, -0.5) # back away
for _ in range(25):
sim.advance(0.02)
print("backing away for 0.5 s: bump() = %s, contact = %s" % (sim.bump(), sim.contact))
first bump at t = 2.36 s, x = 2.9081 m (body gap 1.9 mm), true wheel speed now 0.024 m/s
after 6 s of pushing: x = 2.9100 m (moved 0.0019 m since the bump), bump() = True, bumps = 182 of 300 passes
gap between the body and the wall now: 0.007 mm (one 5 ms physics step at 0.4 m/s is 2 mm)
the encoders did not count the pushing: ticks (2673, 2673), odometry would say 0.9097 m, the robot moved 0.9100 m
backing away for 0.5 s: bump() = False, contact = None
The first bump comes at 2.36 s with the body 1.9 mm from the wall (one physics step away, as the model promised), and the wheel speed drops to nearly nothing as the step is undone and the wheels are stopped. Pushing for six more seconds moves the robot a further 1.9 mm as it creeps to a stop at the wall, and bump() stays true on every pass after the first. The encoders count 2673 ticks, 0.9097 m of wheel travel, which matches the 0.9100 m the robot really moved: they stopped when the wheels did, and a stalled real robot would behave the same (a real one would also draw a lot of current, which Robotics Fundamentals 10 section 7 turns into a stall check). Reversing clears the contact: bump() is false and contact is None.
9. Recording a run and drawing it
A run that scrolls past on the screen cannot be checked, compared or plotted. Recorder keeps one row per control pass, the same way Programming for Robotics 1 section 10 logged a robot: the time, the true pose, the true wheel speeds, what the robot actually read (the noisy range, the tick counts), and the bump flag. Putting the truth and the reading in one row is the main thing a simulated log has that a real one cannot. to_csv writes it as text, with fixed digits, so two logs from two runs can be compared character by character. run is the loop of section 8 as a function, and ascii_map draws the arena from above as a grid of characters. A character is about twice as tall as it is wide, so each cell is twice as tall as wide in metres; # is an obstacle or wall, * the path, S the start and E the end, with y pointing up.
class Recorder:
"""Keeps one row per control pass: time, TRUE pose, true wheel speeds, what the robot read, the bump flag."""
FIELDS = ("t_s", "x_m", "y_m", "theta_rad", "vl_mps", "vr_mps", "range_m", "ticks_l", "ticks_r", "bump")
def __init__(self):
self.rows = []
def record(self, sim):
x, y, theta = sim.pose()
vl, vr = sim.model.true_speeds_mps()
tl, tr = sim.ticks()
self.rows.append((sim.now_s(), x, y, theta, vl, vr, sim.range_m(), tl, tr, 1 if sim.bump() else 0))
def path(self):
return [(row[1], row[2]) for row in self.rows]
def to_csv(self):
lines = [",".join(self.FIELDS)]
for row in self.rows:
cells = []
for value in row:
if value is None:
cells.append("") # no reading: an empty cell
elif isinstance(value, float):
cells.append("%.5f" % value)
else:
cells.append(str(value))
lines.append(",".join(cells))
return "\n".join(lines) + "\n"
def run(sim, robot, seconds, dt_s=0.02, recorder=None):
"""The closed loop: the world moves, then the robot senses, decides and acts (as 'simulate' in section 7)."""
for _ in range(int(round(seconds / dt_s))):
sim.advance(dt_s)
robot.update(dt_s)
if recorder is not None:
recorder.record(sim)
def ascii_map(world, path=(), cols=60, start=True):
"""The arena seen from above as a grid of characters: # obstacle, * path, S start, E end. y points up."""
cell_w = world.width_m / cols
cell_h = 2 * cell_w # a character is about twice as tall as it is wide
rows = int(math.ceil(world.height_m / cell_h - 1e-9))
grid = [[" "] * cols for _ in range(rows)]
def put(x, y, ch):
c = min(cols - 1, max(0, int(x / cell_w)))
r = min(rows - 1, max(0, rows - 1 - int(y / cell_h)))
grid[r][c] = ch
for x1, y1, x2, y2 in world.segments:
n = int(math.hypot(x2 - x1, y2 - y1) / (cell_w / 2)) + 1
for i in range(n + 1):
put(x1 + (x2 - x1) * i / n, y1 + (y2 - y1) * i / n, "#")
for cx, cy, r in world.circles:
for i in range(72):
a = 2 * math.pi * i / 72
put(cx + r * math.cos(a), cy + r * math.sin(a), "#")
for x, y in path:
put(x, y, "*")
if path and start:
put(path[0][0], path[0][1], "S")
put(path[-1][0], path[-1][1], "E")
return "\n".join("".join(row).rstrip() for row in grid)
A path is more interesting than a straight line, so the next demonstration drives a route open loop, from a script of (left duty, right duty, seconds) steps, around a post and past a box: east, a left quarter-circle of radius 0.3 m, north, a second left quarter-circle of 0.25 m, then west. The duties come from the arc formulas of Robotics Fundamentals 4 section 3 (for R = 0.3 m the inner wheel runs at (R − L/2) / (R + L/2) = 0.6 of the outer one). The recorder takes a row on every pass:
def drive_script(sim, script, recorder, dt_s=0.02):
"""Open-loop driving: a list of (left duty, right duty, seconds) steps."""
for left, right, seconds in script:
sim.set_duty(left, right)
for _ in range(int(round(seconds / dt_s))):
sim.advance(dt_s)
recorder.record(sim)
sim.set_duty(0.0, 0.0)
course = World(3.0, 2.0)
course.add_box(1.9, 0.5, 2.2, 1.1)
course.add_circle(1.1, 1.4, 0.2)
sim = Simulator(course, seed=2, pose=(0.3, 0.5, 0.0))
rec = Recorder()
drive_script(sim, [(0.6, 0.6, 4.8), (0.36, 0.6, 2.454), (0.6, 0.6, 2.92), (0.323, 0.6, 2.127), (0.6, 0.6, 1.67)], rec)
csv = rec.to_csv()
print("\n".join(csv.splitlines()[:4]))
print("... %d rows, %d bytes" % (len(rec.rows), len(csv)))
print()
print(ascii_map(course, rec.path(), cols=60))
print()
x, y, theta = sim.pose()
print("end pose: x = %.3f m, y = %.3f m, heading = %.1f deg, bumps = %d" % (x, y, math.degrees(theta), sim.bumps))
The picture shows a start, a route that clears the post and the box with room to spare, and an end pose with the heading 179.9 degrees, nearly due west, as scripted. The top rows of the CSV show what a log row looks like: the first rows show the lag at work (the true wheel speed climbs 0.053, 0.094, 0.127 m/s towards the 0.24 m/s commanded) and the tick counts 1, 6, 12. If you have matplotlib installed, plotting the CSV is three lines (plt.plot(x_column, y_column) and plt.show(), from memory and not run here); it is optional, and the text map is what makes every run in this chapter checkable in a file and in a test.
10. Replay and assertions: tests that use the simulator
A test is a simulated run followed by assertions about the log or the final state. Two kinds are worth writing for every behaviour: does it do the right thing (the robot stops before the wall, never goes faster than the cruise speed) and is the run itself reproducible (the same seed gives the same log, a different seed gives different noise). The second is a test of the simulator, and it catches the commonest simulator bug there is: a hidden source of randomness or of wall-clock time that makes runs differ.
def wall_run(seed, seconds=14.0):
"""The Chapter 1 robot against a wall 3.0 m from the arena's west wall; returns the pieces for the tests."""
sim = Simulator(arena, seed=seed, pose=(0.3, 1.0, 0.0))
robot = Robot(sim)
rec = Recorder()
run(sim, robot, seconds, 0.02, rec)
return sim, robot, rec
def test_robot_stops_before_the_wall():
sim, robot, rec = wall_run(1)
gap_m = 3.0 - (sim.pose()[0] + sim.params.body_radius_m)
assert sim.bumps == 0, "the robot hit the wall"
assert gap_m > 0.20, "the robot stopped too close"
assert max(row[4] for row in rec.rows) <= Config.CRUISE_SPEED_MPS + 0.005, "faster than the cruise speed"
return gap_m
def test_same_seed_same_log():
first = wall_run(5)[2].to_csv()
second = wall_run(5)[2].to_csv()
assert first == second, "two runs with the same seed differ"
return len(first.splitlines())
def test_different_seed_different_noise():
first = wall_run(5)[2].to_csv()
other = wall_run(6)[2].to_csv()
assert first != other, "the noise did not change with the seed"
differing = sum(1 for a, b in zip(first.splitlines(), other.splitlines()) if a != b)
return differing
print("stops before the wall: body gap %.3f m" % test_robot_stops_before_the_wall())
print("same seed, same log: %d identical lines" % test_same_seed_same_log())
print("different seed, different log: %d lines differ" % test_different_seed_different_noise())
print("all tests passed")
stops before the wall: body gap 0.230 m
same seed, same log: 701 identical lines
different seed, different log: 700 lines differ
all tests passed
All three pass. The first checks the behaviour (the body is 23 cm from the wall, there was no bump, and no wheel went faster than the cruise speed plus a tolerance). The second runs the whole 14 s scenario twice with the same seed and compares the two CSV texts for equality: 701 identical lines, byte for byte. The third changes the seed and finds that 700 of the 701 lines differ (the header is the one that does not), which proves the noise really depends on the seed. This is the replay: given a seed, any run that ever failed can be run again and again while the code is fixed.
11. Measuring odometry drift in the simulator
Robotics Fundamentals 4 section 6 worked out by hand what odometry error does to a robot: a right tyre 1 % too big, and a track width 3 % out. Here is the same experiment on the simulator, where the truth is available to compare with. OdometryTracker is a short dead-reckoning class: it turns the encoder counts into distances with the nominal wheel radius and track width (what the software believes) and applies the exact arc update of section 5. It is an instrument for this chapter; Programming for Robotics 5 builds the real odometry, with calibration.
The robot drives “straight” (the same duty on both wheels) until its odometry says 3.0 m, and then coasts to a stop. Its right wheel is really 1 % bigger (scale_right = 1.01), a fact that only the simulated world knows. Next to the simulator’s truth is the formula of Robotics Fundamentals 4 section 6, applied to the distance the left wheel really rolled. The block ends with the second experiment, the track width of 0.155 m against the 0.150 m believed, in a spin of “360 degrees”:
NOMINAL = RobotParams() # what the SOFTWARE believes about the robot
big_arena = World(10.0, 4.0)
def drive_straight(scale_right, distance_m=3.0):
"""Both wheels at the same duty until odometry says distance_m; the right tyre is really scale_right times bigger."""
true_params = RobotParams(scale_right=scale_right)
sim = Simulator(big_arena, params=true_params, pose=(1.0, 2.0, 0.0))
odo = OdometryTracker(NOMINAL) # odometry uses the nominal dimensions, as on a real robot
sim.set_duty(0.5, 0.5)
while odo.x < distance_m:
sim.advance(0.02)
odo.update(*sim.ticks())
sim.set_duty(0.0, 0.0)
for _ in range(50): # let the motors coast to a stop
sim.advance(0.02)
odo.update(*sim.ticks())
return odo, sim
def formula(d_left, ratio, track_m):
"""Robotics Fundamentals 4 section 6: the left wheel rolls d_left, the right wheel ratio times as far."""
d_right = d_left * ratio
ds, dtheta = 0.5 * (d_left + d_right), (d_right - d_left) / track_m
radius = ds / dtheta
return radius * math.sin(dtheta), radius * (1 - math.cos(dtheta)), dtheta
odo, sim = drive_straight(1.01)
x, y, theta = sim.pose()
true_x, true_y, true_theta = x - 1.0, y - 2.0, theta
fx, fy, ftheta = formula(odo.x, 1.01, NOMINAL.track_width_m)
print("odometry says: x = %.4f m, y = %.4f m, heading = %.2f deg" % (odo.x, odo.y, math.degrees(odo.theta)))
print("simulator truth: x = %.4f m, y = %.4f m, heading = %.2f deg" % (true_x, true_y, math.degrees(true_theta)))
print("formula: x = %.4f m, y = %.4f m, heading = %.2f deg" % (fx, fy, math.degrees(ftheta)))
print("sim - formula: %.2f mm in x, %.2f mm in y; the drift is %.1f cm sideways and %.1f deg of heading"
% (1000 * (true_x - fx), 1000 * (true_y - fy), 100 * (true_y - odo.y), math.degrees(true_theta - odo.theta)))
# RF4 worked example 2: the true track width is 0.155 m, the software believes 0.150 m; turn "360 degrees"
true_params = RobotParams(track_width_m=0.155, tau_s=0.0)
spin = Simulator(big_arena, params=true_params, pose=(1.0, 2.0, 0.0))
odo_angle, true_angle, last_theta = 0.0, 0.0, spin.pose()[2]
duty = 0.25
omega = 2 * duty * NOMINAL.max_speed_mps / NOMINAL.track_width_m # what the software thinks the turn rate is
dt = (2 * math.pi / omega) / 400 # 400 passes make exactly 360 degrees
spin.set_duty(-duty, duty)
left0, right0 = spin.ticks()
for _ in range(400):
spin.advance(dt)
true_angle += wrap_pi(spin.pose()[2] - last_theta)
last_theta = spin.pose()[2]
left, right = spin.ticks()
odo_angle = (right - right0 - (left - left0)) * 2 * math.pi * NOMINAL.wheel_radius_m / NOMINAL.ticks_per_rev / NOMINAL.track_width_m
print("spin: odometry says %.1f deg, the robot really turned %.1f deg (RF4: 348.4); ratio %.4f (0.150 / 0.155 = %.4f)"
% (math.degrees(odo_angle), math.degrees(true_angle), true_angle / odo_angle, 0.150 / 0.155))
odometry says: x = 3.0198 m, y = 0.0000 m, heading = 0.00 deg
simulator truth: x = 3.0146 m, y = 0.3045 m, heading = 11.54 deg
formula: x = 3.0145 m, y = 0.3045 m, heading = 11.53 deg
sim - formula: 0.17 mm in x, 0.03 mm in y; the drift is 30.5 cm sideways and 11.5 deg of heading
spin: odometry says 360.0 deg, the robot really turned 348.4 deg (RF4: 348.4); ratio 0.9678 (0.150 / 0.155 = 0.9677)
Odometry thinks it drove dead straight: y = 0 and heading 0, as it must, because the two tick counts are equal. The truth is 30.5 cm to the side and 11.5 degrees round. The formula, which is a few lines of algebra with no simulation in it, agrees with the simulator to 0.17 mm in x and 0.03 mm in y. That agreement is the check on both: the formula describes the robot, and the simulator implements the model the formula describes. The numbers differ from Robotics Fundamentals 4 section 6 (2.995 m, 0.300 m, 11.46 degrees) only because the run is longer: the motors coast 1.6 cm after the stop command, so the odometry distance is 3.0198 m, not 3.000 m, and the sideways drift grows with the square of the distance, 0.3005 × (3.0198 / 3.000)2 = 0.3045 m. The spin is the second worked example: the robot believes it turned 360.0 degrees and actually turned 348.4 degrees, the number of the book, and the ratio of the two angles, 0.9678, is the ratio of the track widths, 0.150 / 0.155 = 0.9677, which is also how the calibration of Robotics Fundamentals 4 section 6 recovers the true track width.
12. Fidelity against cost: the step and the rays
A simulator is always a bargain between accuracy and the time it takes. There are two dials, and both are easy to get wrong in a way that gives no error message:
The physics step. Too coarse and the pose integration is inaccurate (section 5) and the robot can move through a thin wall in a single step: at 0.4 m/s a 20 ms step moves 8 mm, so a wall thinner than that, or a collision test that only looks at the end of each step, could miss a hit. Too fine and a long run is slow. The rule of thumb: the distance moved in one physics step should be much smaller than the smallest obstacle and than the tolerances you care about (0.4 m/s × 5 ms = 2 mm here), and the motion model should not be sensitive to the step (the arc update is not).
The number of rays. Each ray costs one test per wall segment and circle, every sensor update. More rays find thinner things, at a linear cost.
The control period is not a dial. It is whatever the real robot’s loop period is (0.02 s here, 50 Hz), because the robot code, with its filters and its gains, is written for that dt. Only the physics step is the simulator’s own.
First the step: a lagged curve for 6 s, integrated with euler and with the arc update at several steps, against a reference of the arc update with a step of 0.2 ms. Then the rays: a post of radius 2.5 cm (a 5 cm pole) 2.03 m from the sensor, moved across a 30 degree cone, and what share of positions each ray count finds. The cost is counted, not timed (a count does not change from one PC to the next), as ray tests per second at the 50 Hz pass rate:
# (a) the physics step: a lagged curve, 6 s, integrated with different steps and methods
def curve_end(method, dt_s, seconds=6.0):
model = RobotModel(RobotParams(), integrator=method)
model.set_wheel_speeds(0.15, 0.35)
for _ in range(int(round(seconds / dt_s))):
model.step(dt_s)
return model.x, model.y
rx, ry = curve_end("arc", 0.0002) # the reference: the exact arc update with tiny steps
print("physics dt steps euler error arc error")
for dt in (0.1, 0.02, 0.005, 0.001):
ex, ey = curve_end("euler", dt)
ax, ay = curve_end("arc", dt)
print(" %-8s %6d %8.2f mm %8.3f mm" % (dt, int(6.0 / dt), 1000 * math.hypot(ex - rx, ey - ry),
1000 * math.hypot(ax - rx, ay - ry)))
# (b) the ray count: can a 5 cm post, 2 m away, slip between the rays of a 30 degree cone?
print()
print("rays spacing share of post positions detected ray tests per second at 50 Hz")
for n_rays in (1, 5, 9, 21, 23, 31):
found, trials = 0, 0
tests = 0
for k in range(-108, 109): # the post sits at 2.03 m, from -0.54 m to +0.54 m sideways
world_p = World(4.0, 3.0)
world_p.add_circle(2.5, 1.5 + k * 0.005, 0.025)
sensor = RangeSensor(world_p, random.Random(1), fov_rad=math.radians(30), n_rays=n_rays)
bearing = math.atan2(k * 0.005, 2.03)
if abs(bearing) <= math.radians(15): # only posts inside the cone count as trials
trials += 1
r = sensor.true_range((0.4, 1.5, 0.0))
found += 1 if (r is not None and abs(r - 2.0) < 0.1) else 0
tests = world_p.ray_tests
spacing = "-" if n_rays == 1 else "%.2f deg" % (30 / (n_rays - 1))
print("%-5d %-8s %5.1f %% %d" % (n_rays, spacing, 100 * found / trials, 50 * tests))
print("post size seen from 2.03 m: %.2f deg, so the rays need to be at most that far apart: n - 1 >= %.1f"
% (math.degrees(2 * math.atan(0.025 / 2.03)), 30 / math.degrees(2 * math.atan(0.025 / 2.03))))
physics dt steps euler error arc error
0.1 60 17.48 mm 0.000 mm
0.02 300 3.51 mm 0.000 mm
0.005 1200 0.88 mm 0.000 mm
0.001 6000 0.18 mm 0.000 mm
rays spacing share of post positions detected ray tests per second at 50 Hz
1 - 5.1 % 250
5 7.50 deg 18.9 % 1250
9 3.75 deg 37.3 % 2250
21 1.50 deg 92.6 % 5250
23 1.36 deg 100.0 % 5750
31 1.00 deg 100.0 % 7750
post size seen from 2.03 m: 1.41 deg, so the rays need to be at most that far apart: n - 1 >= 21.3
The step table says euler needs a step of about a millisecond to get under 0.2 mm, which is 6000 steps for a 6 s run, where the arc update is exact at 60 steps. It is cheaper to be exact than to be fine. (The arc column is exactly zero here for the reason given in section 5: a start from rest with constant commands is an exact arc. A run of changing commands would show a small error, much smaller than the euler column.) The ray table is the more surprising one. A pole 5 cm wide seen from 2 m is only 1.41 degrees across, so rays further apart than that can slide past it. With 5 rays (7.5 degrees apart) the sensor finds the pole in 18.9 % of its positions, 9 rays find 37.3 %, 21 rays find 92.6 %, and 23 rays or more find every position. That is a discovery by arithmetic before the experiment: the rays must be no further apart than the angular size of the smallest object that matters, 2 atan(r / d) = 1.41 degrees, so the 30 degree cone needs at least 30 / 1.41 + 1 = 22.3, in practice 23 rays. A 1 ray sensor has the pole in view only 5.1 % of the time, and a real ultrasonic sensor with its wide beam finds the pole everywhere: a one-ray model of it would be badly pessimistic. The cost is 5750 ray tests a second for 23 rays on this small arena, trivial for a PC and the reason not to put 23 rays on every sensor of a big map.
13. The reality gap
Everything above is a model, and the robot is not. The honest way to use a simulator is to write down what it leaves out and decide which omissions can break the program being tested. These are the ones sim.py leaves out, what each does to a real robot, and what it would take to add it:
Left out
What it does on the real robot
To add it, or to find out
Friction and the motor dead band
A small duty does not move the wheel at all (Programming for Robotics 2 section 2: the dead band), so a slow, careful approach stalls
In set_duty, treat a duty below the dead band as zero; test the lowest speed the controller asks for
Battery sag
The same duty gives a lower speed as the battery drains, and the robot slows down during a run
Multiply the maximum speed by a factor that falls over the run; measure it with battery_v on the real robot
Sensor crosstalk and false echoes
Outliers: a wrong wall, a missed pulse, two sensors hearing each other (Robotics Fundamentals 5 section 3)
An outlier probability in _measure; Programming for Robotics 4 filters them
Wheel slip
The encoders count more than the robot moved, differently each time, so odometry drifts at random (Robotics Fundamentals 4 section 6)
Challenge 2 adds it; scale_left and scale_right give the systematic part
Latency and jitter
The sensor reading is old when the code gets it (the latency chain of Programming for Robotics 1 section 12); the loop does not run at a perfectly fixed period
The latency_s option, below; jitter is not modelled (the clock is exact), measure it on the robot with LoopStats
Acceleration limits, inertia, load
A real robot cannot change speed instantly even with a perfect motor; a heavier robot lags more
Replace the first-order lag with a limit on the change of speed per step, or a second-order model
Uneven floor, slopes, a lifted wheel
Wheel travel is not ground travel; the robot tilts and the sensors point at the floor
Out of scope: the world is flat and two-dimensional
The sensor’s real beam and surface effects
Soft surfaces absorb sound, angled walls reflect it away, a thin pole may or may not be seen
The cone of rays is an approximation (section 12); only the real sensor and a real wall settle it
Computation time
The real loop may overrun its period on a slow board; the simulated code never does
Time the code on the board (Programming for Robotics 1 section 12), never in the simulator
Of these, the one the simulator can show quickly is latency, because the sensor option exists. The experiment is the Programming for Robotics 1 controller against a delayed range sensor, run twice: the original controller, which slows down gently (gain 0.30 / 0.35 = 0.86 per second), and a sharper one that slows down between 0.30 and 0.25 m (gain 0.30 / 0.05 = 6 per second). Each table entry is the closest the robot ever got to the wall, from a 14 s run at each delay:
class SharpConfig(Config):
"""The Chapter 1 controller with a sharper stop: gain 0.30 / (0.30 - 0.25) = 6 per second instead of 0.86."""
SLOW_RANGE_M = 0.30
def approach(latency_s, cfg):
sim = Simulator(arena, seed=1, pose=(0.3, 1.0, 0.0), latency_s=latency_s)
robot = Robot(sim, cfg)
closest = 9.9
for _ in range(int(14.0 / 0.02)):
sim.advance(0.02)
robot.update(0.02)
closest = min(closest, sim.range_sensors["front"].true_range(sim.pose()) or 9.9)
return closest, sim.bumps
print("sensor delay gentle controller (stop line 0.250 m) sharp controller")
for latency in (0.0, 0.1, 0.2, 0.3, 0.5, 0.8):
gentle, _ = approach(latency, Config)
sharp, bumps = approach(latency, SharpConfig)
print(" %.1f s closest true range %.3f m closest %.3f m%s"
% (latency, gentle, sharp, " (the robot hit the wall)" if bumps else ""))
sensor delay gentle controller (stop line 0.250 m) sharp controller
0.0 s closest true range 0.250 m closest 0.237 m
0.1 s closest true range 0.249 m closest 0.210 m
0.2 s closest true range 0.249 m closest 0.181 m
0.3 s closest true range 0.248 m closest 0.154 m
0.5 s closest true range 0.229 m closest 0.091 m
0.8 s closest true range 0.150 m closest 0.020 m (the robot hit the wall)
The gentle controller shrugs off a delay of 0.3 s and starts to overshoot the stop line at 0.5 s (0.229 m); it still does not hit anything at 0.8 s, though it gets to 0.150 m. The sharp controller loses its margin at once: even with no sensor delay it overshoots a little (0.237 m, because of the motor lag and the range filter), and at 0.8 s it hits the wall. A standard result for delayed feedback (from memory, and shown here only by this table) explains the pattern: for a rule of the form “the speed is proportional to how far the reading is above the stop line”, with gain k per second and a delay T, the approach is smooth when kT is under 1/e = 0.37, overshoots when it is over, and becomes unstable at kT over π/2 = 1.57. For the gentle controller that puts the overshoot at a delay of about 0.43 s, and for the sharp one at about 0.06 s (both a little earlier here, since the motor lag adds to the delay): what the table shows. The lesson is the reason for having a simulator at all: this robot, a controller with a high gain, and a slow sensor is a crash that costs nothing to find in the simulator and a broken robot to find on the floor.
Measure the model, then trust it a little
Every number that describes the simulated robot should come from the real one once the robot exists: the motor time constant from a step response, the noise of the range sensor from a few hundred readings of a fixed wall (the mean and standard deviation of section 6), the encoder ticks per wheel revolution by turning the wheel by hand, the latency from the latency chain of Programming for Robotics 1 section 12. A simulator with measured parameters is better than one with guessed parameters, and it is still a model: when the robot and the simulator disagree, the robot is right.
14. The reference listing: sim.py
The blocks of sections 3 to 7, 9 and 11, in order, are the complete module. This is the one file later chapters mean by “the simulator from Programming for Robotics 3”; save it as sim.py. It needs only the standard library. (A copy that is the same as this listing was run against every demonstration of this chapter before the chapter was finished.)
# sim.py -- the simulator from Programming for Robotics 3: a small 2D differential-drive robot world (standard library only)
# Hardware interface of Programming for Robotics 1 section 7: range_m(), ticks(), set_duty(left, right); plus line(), bump(), now_s(), advance(dt_s)
import math
import random
from collections import deque
def clamp(value, low, high):
return max(low, min(high, value))
def wrap_pi(angle):
"""Wrap an angle in radians to the range -pi .. +pi."""
return math.atan2(math.sin(angle), math.cos(angle))
def ray_segment(ox, oy, dx, dy, x1, y1, x2, y2):
"""Distance along the ray (origin ox, oy; UNIT direction dx, dy) to the segment, or None."""
ex, ey = x2 - x1, y2 - y1 # the segment's direction vector e
denom = dx * ey - dy * ex # cross(d, e); zero when the ray is parallel to the segment
if abs(denom) < 1e-12:
return None
qx, qy = x1 - ox, y1 - oy # q = from the ray origin to the segment start
t = (qx * ey - qy * ex) / denom # cross(q, e) / cross(d, e): distance along the ray
u = (qx * dy - qy * dx) / denom # cross(q, d) / cross(d, e): position along the segment, 0..1
if t >= 0.0 and 0.0 <= u <= 1.0:
return t
return None
def ray_circle(ox, oy, dx, dy, cx, cy, r):
"""Distance along the ray (UNIT direction) to the near side of the circle, or None."""
fx, fy = ox - cx, oy - cy # from the circle centre to the ray origin
b = dx * fx + dy * fy # d . f
c = fx * fx + fy * fy - r * r # |f|^2 - r^2
disc = b * b - c # the discriminant of t^2 + 2 b t + c = 0
if disc < 0.0:
return None # the line misses the circle
t = -b - math.sqrt(disc) # the nearer of the two crossings
return t if t >= 0.0 else None # behind the origin, or the origin is inside the circle
def closest_on_segment(px, py, x1, y1, x2, y2):
"""The point of the segment nearest to (px, py)."""
ex, ey = x2 - x1, y2 - y1
length_sq = ex * ex + ey * ey
u = 0.0 if length_sq == 0.0 else clamp(((px - x1) * ex + (py - y1) * ey) / length_sq, 0.0, 1.0)
return x1 + u * ex, y1 + u * ey
class World:
"""A rectangular arena with wall segments, circular obstacles and painted lines. Metres."""
def __init__(self, width_m, height_m):
self.width_m = width_m
self.height_m = height_m
w, h = width_m, height_m
self.segments = [(0, 0, w, 0), (w, 0, w, h), (w, h, 0, h), (0, h, 0, 0)] # the four arena walls
self.circles = [] # (cx, cy, r)
self.lines = [] # painted lines: (list of (x, y) points, width_m)
self.ray_tests = 0 # how many ray-shape tests were done (a cost counter)
def add_wall(self, x1, y1, x2, y2):
self.segments.append((x1, y1, x2, y2))
def add_box(self, x0, y0, x1, y1):
self.add_wall(x0, y0, x1, y0)
self.add_wall(x1, y0, x1, y1)
self.add_wall(x1, y1, x0, y1)
self.add_wall(x0, y1, x0, y0)
def add_circle(self, cx, cy, r):
self.circles.append((cx, cy, r))
def add_line(self, points, width_m=0.02):
self.lines.append((list(points), width_m))
def raycast(self, ox, oy, angle, max_range_m):
"""Distance from (ox, oy) along the angle to the nearest thing, or None if nothing within max_range_m."""
dx, dy = math.cos(angle), math.sin(angle)
best = None
for x1, y1, x2, y2 in self.segments:
self.ray_tests += 1
t = ray_segment(ox, oy, dx, dy, x1, y1, x2, y2)
if t is not None and (best is None or t < best):
best = t
for cx, cy, r in self.circles:
self.ray_tests += 1
t = ray_circle(ox, oy, dx, dy, cx, cy, r)
if t is not None and (best is None or t < best):
best = t
return best if (best is not None and best <= max_range_m) else None
def contact(self, x, y, radius_m):
"""Does a round robot body at (x, y) overlap anything? None, or (nx, ny, depth_m): the deepest overlap,
with (nx, ny) the unit vector pointing from the obstacle towards the robot."""
found = None
for x1, y1, x2, y2 in self.segments:
qx, qy = closest_on_segment(x, y, x1, y1, x2, y2)
d = math.hypot(x - qx, y - qy)
if 0.0 < d < radius_m and (found is None or radius_m - d > found[2]):
found = ((x - qx) / d, (y - qy) / d, radius_m - d)
for cx, cy, r in self.circles:
d = math.hypot(x - cx, y - cy)
if 0.0 < d < radius_m + r and (found is None or radius_m + r - d > found[2]):
found = ((x - cx) / d, (y - cy) / d, radius_m + r - d)
return found
def on_line(self, x, y):
"""Is the point on paint?"""
for points, width_m in self.lines:
for (x1, y1), (x2, y2) in zip(points, points[1:]):
qx, qy = closest_on_segment(x, y, x1, y1, x2, y2)
if math.hypot(x - qx, y - qy) <= width_m / 2:
return True
return False
def advance_pose(x, y, theta, ds, dtheta, method="arc"):
"""Move a pose by the centre travel ds and the turn dtheta (the unicycle model)."""
if method == "euler": # the crude one: the OLD heading for the whole step
x += ds * math.cos(theta)
y += ds * math.sin(theta)
elif method == "midpoint" or abs(dtheta) < 1e-9:
x += ds * math.cos(theta + dtheta / 2) # the heading half way through (also exact for a straight line)
y += ds * math.sin(theta + dtheta / 2)
else: # "arc": exact for constant wheel speeds
radius = ds / dtheta
x += radius * (math.sin(theta + dtheta) - math.sin(theta))
y -= radius * (math.cos(theta + dtheta) - math.cos(theta))
return x, y, wrap_pi(theta + dtheta)
class RobotParams:
"""The physical robot. The defaults are the build robot of Robotics Fundamentals 10 section 9."""
def __init__(self, wheel_radius_m=0.0325, track_width_m=0.150, ticks_per_rev=600,
max_speed_mps=0.40, tau_s=0.08, body_radius_m=0.09, scale_left=1.0, scale_right=1.0):
self.wheel_radius_m = wheel_radius_m # the radius the SOFTWARE believes
self.track_width_m = track_width_m
self.ticks_per_rev = ticks_per_rev # encoder counts per wheel revolution
self.max_speed_mps = max_speed_mps # wheel speed at duty 1.0
self.tau_s = tau_s # motor time constant: 0 means an instant response
self.body_radius_m = body_radius_m # the robot is a disc of this radius
self.scale_left = scale_left # ground distance per wheel radian, relative to the belief:
self.scale_right = scale_right # 1.01 = the tyre is really 1 % bigger; 0.98 = 2 % slip
class RobotModel:
"""True pose, true wheel motion. Wheel speed COMMANDS go in; true wheel angles and the pose come out."""
def __init__(self, params, x=0.0, y=0.0, theta=0.0, integrator="arc"):
self.p = params
self.x, self.y, self.theta = x, y, theta
self.integrator = integrator
self.cmd_w = [0.0, 0.0] # commanded wheel speeds, rad/s
self.w = [0.0, 0.0] # true wheel speeds, rad/s
self.angle = [0.0, 0.0] # total angle each wheel has turned, rad
def set_wheel_speeds(self, left_mps, right_mps):
p = self.p
limit = p.max_speed_mps
self.cmd_w = [clamp(left_mps, -limit, limit) / p.wheel_radius_m,
clamp(right_mps, -limit, limit) / p.wheel_radius_m]
def true_speeds_mps(self):
p = self.p
return (self.w[0] * p.wheel_radius_m * p.scale_left, self.w[1] * p.wheel_radius_m * p.scale_right)
def step(self, dt_s):
p = self.p
scale = (p.scale_left, p.scale_right)
dist = [0.0, 0.0]
for i in (0, 1):
w0, wc = self.w[i], self.cmd_w[i]
if p.tau_s > 0.0: # first-order lag: dw/dt = (wc - w) / tau, solved exactly
decay = math.exp(-dt_s / p.tau_s)
turned = wc * dt_s + (w0 - wc) * p.tau_s * (1.0 - decay) # the integral of w over the step
self.w[i] = wc + (w0 - wc) * decay
else:
turned = wc * dt_s
self.w[i] = wc
self.angle[i] += turned
dist[i] = turned * p.wheel_radius_m * scale[i] # metres rolled along the ground
ds = 0.5 * (dist[0] + dist[1])
dtheta = (dist[1] - dist[0]) / p.track_width_m
self.x, self.y, self.theta = advance_pose(self.x, self.y, self.theta, ds, dtheta, self.integrator)
def snapshot(self):
return (self.x, self.y, self.theta, list(self.angle))
def restore(self, snap):
self.x, self.y, self.theta = snap[0], snap[1], snap[2]
self.angle = list(snap[3])
self.w = [0.0, 0.0] # the wheels were stopped by the obstacle
class RangeSensor:
"""An ultrasonic or infrared style range sensor: rays cast into the world, noise, limits, rate and delay."""
def __init__(self, world, rng, mount=(0.07, 0.0, 0.0), max_range_m=4.0, min_range_m=0.02, sigma_m=0.005,
fov_rad=0.0, n_rays=1, period_s=0.0, latency_s=0.0):
self.world = world
self.rng = rng # this sensor's own random.Random: no shared global state
self.mount = mount # (forward, left, angle) in the robot's frame
self.max_range_m = max_range_m
self.min_range_m = min_range_m
self.sigma_m = sigma_m # standard deviation of the Gaussian noise
self.fov_rad = fov_rad # width of the cone; with n_rays rays spread across it
self.n_rays = n_rays
self.period_s = period_s # 0 = measure on every update; 0.025 = 40 Hz
self.latency_s = latency_s # age of the reading when you read it
self._next_t = 0.0
self._queue = deque() # (time measured, reading), oldest first
self._held = None # the newest reading that is old enough to be delivered
def true_range(self, pose):
x, y, theta = pose
fwd, left, mount_angle = self.mount
ox = x + fwd * math.cos(theta) - left * math.sin(theta)
oy = y + fwd * math.sin(theta) + left * math.cos(theta)
best = None
for k in range(self.n_rays):
offset = 0.0 if self.n_rays == 1 else -self.fov_rad / 2 + self.fov_rad * k / (self.n_rays - 1)
r = self.world.raycast(ox, oy, theta + mount_angle + offset, self.max_range_m)
if r is not None and (best is None or r < best):
best = r
return best
def _measure(self, pose):
noise = self.rng.gauss(0.0, self.sigma_m) # always draw one number: the random stream never depends on the geometry
r = self.true_range(pose)
if r is None:
return None # nothing in range: a timeout
r += noise
if r < self.min_range_m or r > self.max_range_m:
return None
return r
def update(self, pose, now_s):
if now_s + 1e-9 < self._next_t:
return # not time for a new measurement yet
self._next_t = now_s + self.period_s
self._queue.append((now_s, self._measure(pose)))
def read(self, now_s):
while self._queue and self._queue[0][0] <= now_s - self.latency_s + 1e-9:
self._held = self._queue.popleft()[1]
return self._held
class Encoders:
"""Two wheel encoders: whole counts of the wheel angle, with an optional counting scale error and a bias."""
def __init__(self, ticks_per_rev, scale=(1.0, 1.0), bias=(0, 0)):
self.ticks_per_rev = ticks_per_rev
self.scale = scale
self.bias = bias
def read(self, angles_rad):
return tuple(int(math.floor(a / (2 * math.pi) * self.ticks_per_rev * s)) + b
for a, s, b in zip(angles_rad, self.scale, self.bias))
class LineSensor:
"""A row of n reflectance sensors across the front of the robot (index 0 = leftmost); 1 = over the line."""
def __init__(self, world, n=5, spacing_m=0.015, forward_m=0.07):
self.world = world
self.offsets = [((n - 1) / 2 - k) * spacing_m for k in range(n)]
self.forward_m = forward_m
def read(self, pose):
x, y, theta = pose
out = []
for left in self.offsets:
px = x + self.forward_m * math.cos(theta) - left * math.sin(theta)
py = y + self.forward_m * math.sin(theta) + left * math.cos(theta)
out.append(1 if self.world.on_line(px, py) else 0)
return tuple(out)
class BumpSensor:
"""A bumper over the front arc of the body: pressed when the contact point is inside the arc."""
def __init__(self, arc_rad=math.pi):
self.arc_rad = arc_rad
def pressed(self, contact, theta):
if contact is None:
return False
nx, ny, _ = contact # the normal points from the obstacle to the robot,
bearing = wrap_pi(math.atan2(-ny, -nx) - theta) # so the contact point is the opposite way
return abs(bearing) <= self.arc_rad / 2
class SimClock:
"""Simulated time in whole microseconds: exact, deterministic and independent of the PC's speed."""
def __init__(self):
self.us = 0
def now_s(self): # seconds, like time.monotonic()
return self.us / 1000000
def advance(self, dt_s):
self.us += int(round(dt_s * 1000000))
class Simulator:
"""The hardware interface of Programming for Robotics 1 section 7 on a simulated world:
range_m(), ticks(), set_duty(left, right). Also line(), bump(), now_s() and advance(dt_s)."""
def __init__(self, world, params=None, seed=1, pose=(0.3, 0.3, 0.0), physics_dt_s=0.005,
integrator="arc", **range_kwargs):
self.world = world
self.params = params or RobotParams()
self.rng = random.Random(seed) # the one source of randomness; each sensor gets its own stream from it
self.clock = SimClock()
self.model = RobotModel(self.params, pose[0], pose[1], pose[2], integrator)
self.physics_dt_s = physics_dt_s
self.range_sensors = {}
self.add_range_sensor("front", **range_kwargs)
self.encoders = Encoders(self.params.ticks_per_rev)
self.line_sensor = LineSensor(world)
self.bumper = BumpSensor()
self.contact = None # the contact found during the last advance(), if any
self.bumps = 0 # how many advance() calls ended in a collision
for sensor in self.range_sensors.values():
sensor.update(self.pose(), self.clock.now_s())
def add_range_sensor(self, name, **kwargs):
sensor = RangeSensor(self.world, random.Random(self.rng.getrandbits(32)), **kwargs)
self.range_sensors[name] = sensor
return sensor
# ---- the hardware interface ----
def range_m(self):
return self.range_sensors["front"].read(self.clock.now_s())
def range_of(self, name):
return self.range_sensors[name].read(self.clock.now_s())
def ticks(self):
return self.encoders.read(self.model.angle)
def set_duty(self, left, right):
limit = self.params.max_speed_mps
self.model.set_wheel_speeds(clamp(left, -1.0, 1.0) * limit, clamp(right, -1.0, 1.0) * limit)
def line(self):
return self.line_sensor.read(self.pose())
def bump(self):
return self.bumper.pressed(self.contact, self.model.theta)
# ---- the world moves on ----
def now_s(self):
return self.clock.now_s()
def pose(self):
return (self.model.x, self.model.y, self.model.theta)
def advance(self, dt_s):
"""Move the world forward by dt_s: physics in small steps, then one new reading from every sensor."""
n = max(1, int(math.ceil(dt_s / self.physics_dt_s - 1e-9)))
h = dt_s / n
self.contact = None
for _ in range(n):
before = self.model.snapshot()
self.model.step(h)
contact = self.world.contact(self.model.x, self.model.y, self.params.body_radius_m)
if contact is not None: # collision: undo this step, stop the wheels, remember where it touched
self.model.restore(before)
self.contact = contact
if self.contact is not None:
self.bumps += 1
self.clock.advance(dt_s)
for sensor in self.range_sensors.values():
sensor.update(self.pose(), self.clock.now_s())
class Recorder:
"""Keeps one row per control pass: time, TRUE pose, true wheel speeds, what the robot read, the bump flag."""
FIELDS = ("t_s", "x_m", "y_m", "theta_rad", "vl_mps", "vr_mps", "range_m", "ticks_l", "ticks_r", "bump")
def __init__(self):
self.rows = []
def record(self, sim):
x, y, theta = sim.pose()
vl, vr = sim.model.true_speeds_mps()
tl, tr = sim.ticks()
self.rows.append((sim.now_s(), x, y, theta, vl, vr, sim.range_m(), tl, tr, 1 if sim.bump() else 0))
def path(self):
return [(row[1], row[2]) for row in self.rows]
def to_csv(self):
lines = [",".join(self.FIELDS)]
for row in self.rows:
cells = []
for value in row:
if value is None:
cells.append("") # no reading: an empty cell
elif isinstance(value, float):
cells.append("%.5f" % value)
else:
cells.append(str(value))
lines.append(",".join(cells))
return "\n".join(lines) + "\n"
def run(sim, robot, seconds, dt_s=0.02, recorder=None):
"""The closed loop: the world moves, then the robot senses, decides and acts (as 'simulate' in section 7)."""
for _ in range(int(round(seconds / dt_s))):
sim.advance(dt_s)
robot.update(dt_s)
if recorder is not None:
recorder.record(sim)
def ascii_map(world, path=(), cols=60, start=True):
"""The arena seen from above as a grid of characters: # obstacle, * path, S start, E end. y points up."""
cell_w = world.width_m / cols
cell_h = 2 * cell_w # a character is about twice as tall as it is wide
rows = int(math.ceil(world.height_m / cell_h - 1e-9))
grid = [[" "] * cols for _ in range(rows)]
def put(x, y, ch):
c = min(cols - 1, max(0, int(x / cell_w)))
r = min(rows - 1, max(0, rows - 1 - int(y / cell_h)))
grid[r][c] = ch
for x1, y1, x2, y2 in world.segments:
n = int(math.hypot(x2 - x1, y2 - y1) / (cell_w / 2)) + 1
for i in range(n + 1):
put(x1 + (x2 - x1) * i / n, y1 + (y2 - y1) * i / n, "#")
for cx, cy, r in world.circles:
for i in range(72):
a = 2 * math.pi * i / 72
put(cx + r * math.cos(a), cy + r * math.sin(a), "#")
for x, y in path:
put(x, y, "*")
if path and start:
put(path[0][0], path[0][1], "S")
put(path[-1][0], path[-1][1], "E")
return "\n".join("".join(row).rstrip() for row in grid)
class OdometryTracker:
"""Dead reckoning from encoder counts, with the NOMINAL dimensions (Robotics Fundamentals 4 section 5)."""
def __init__(self, params, pose=(0.0, 0.0, 0.0), method="arc"):
self.metres_per_tick = 2 * math.pi * params.wheel_radius_m / params.ticks_per_rev
self.track_width_m = params.track_width_m
self.x, self.y, self.theta = pose
self.method = method
self.left, self.right = 0, 0
def update(self, left_ticks, right_ticks):
d_left = (left_ticks - self.left) * self.metres_per_tick
d_right = (right_ticks - self.right) * self.metres_per_tick
self.left, self.right = left_ticks, right_ticks
ds = 0.5 * (d_left + d_right)
dtheta = (d_right - d_left) / self.track_width_m
self.x, self.y, self.theta = advance_pose(self.x, self.y, self.theta, ds, dtheta, self.method)
Using it takes four lines, and the robot code does not change:
# illustration: how a later chapter uses the simulator (the pieces are all in the blocks above)
from sim import World, Simulator, Recorder, run, ascii_map
from robot import Robot # Programming for Robotics 1 section 6, unchanged
world = World(3.0, 2.0)
sim = Simulator(world, seed=1, pose=(0.3, 1.0, 0.0))
robot = Robot(sim)
run(sim, robot, seconds=14.0)
15. Challenges
Work these with Python and the simulator. The solutions are text files with the working, and the code in them was run, so the output shown is what it printed.
Challenge 1: Tests for the bump sensor
Write three tests with the simulator and a 3.0 m by 2.0 m arena. (a) A robot at x = 2.0 m facing the east wall drives at duty 0.6 until the world reports a contact. Assert that bump() is true, that the robot then stays still (less than 3 mm of movement) while it keeps pushing for one more second, and that the body ends up touching the wall (a gap of less than 3 mm). (b) A robot reverses into the west wall: assert that there is a contact but that the front bumper does not report it, and that a bumper that goes all the way round (BumpSensor(arc_rad=2 * math.pi)) does. (c) A post of radius 0.10 m stands 0.12 m to the left of the robot’s path: find the bearing of the contact from the contact normal and compare it to the bearing from the geometry (the centres are 0.09 + 0.10 m apart when they touch).
Challenge 2: A slip option, and the drift it causes
Add a SlipModel that is a RobotModel in which each wheel, on a random 5 % of the physics steps, loses half of that step’s ground distance (the wheel turns, so the encoders count it, but the robot does not move that far). Use it in a Simulator with its own random.Random(seed). Drive straight at duty 0.5 until odometry says 3.0 m, for 200 seeds. Report the mean ratio of true to odometry distance, and the mean and the standard deviation of the heading error. Then predict them with algebra: the mean loss of distance (1 − 0.05 × 0.5), and the standard deviation of the heading from the variance of a sum of independent random losses, and compare. (Hint: each step each wheel loses d0 × 0.5 with probability 0.05, d0 = 1 mm per physics step at duty 0.5, and the heading changes by the difference of the losses over the track width.)
Challenge 3: A second range sensor, and the highest safe approach speed
A robot drives at a wall and stops (the motors off) as soon as a sensor reads under its threshold. Sensor A is the front sensor: threshold 0.20 m, a delay of 0.30 s, a measurement every 0.10 s, noise 5 mm. (a) The wall crosses the threshold just after a measurement, so the worst-case stop distance is v (delay + period + τ) with τ = 0.08 s, and the sensor must still be at least 0.02 m from the wall (the body front is 2 cm ahead of the sensor) with a margin of three sigma. Derive the highest safe speed for A alone. (b) Check it in the simulator: sweep the duty from 0.50 to 1.00 and find the highest duty before the first bump, over ten start offsets that move where the passes fall. Is the formula safe, and how conservative is it? (c) Add a second sensor B with add_range_sensor: maximum range 0.35 m, noise 2 mm, a measurement every pass, no delay, a threshold of 0.12 m. Compute its safe speed and show by simulation that with B the robot can approach safely at full duty.
Why and what for: fast, safe, repeatable, observable, fault-injectable. Use it for logic bugs, regression tests, design comparisons and first parameter values; never as proof. The reality gap is what it leaves out.
Determinism:random.Random(seed) per random thing, no global random state; time is an integer number of microseconds the simulator advances; draw the noise whatever the outcome. Same seed gives the same log; test over many seeds for the spread.
Conventions: metres, radians, seconds; x east, y north, θ counter-clockwise from x; sensor mount = (forward, left, angle); the world moves first, then the robot reads and acts.
Ray and segment: t = (q × e) / (d × e), u = (q × d) / (d × e), q = p − o, hit if t ≥ 0 and 0 ≤ u ≤ 1; d × e = 0 is parallel. Ray and circle: t = −b − √(b² − c), b = d · f, c = f · f − r², f = o − centre; d must be a unit vector.
Pose update: ds = (dR + dL) / 2, dθ = (dR − dL) / L, R = ds / dθ; x ← x + R (sin(θ + dθ) − sin θ), y ← y − R (cos(θ + dθ) − cos θ); straight if dθ is tiny. Euler error = chord × (step turn) / 2; midpoint is 1 − dθ²/24 off the arc length.
Motor lag: ω moves to the command with time constant τ; angle over a step = ωc h + (ω0 − ωc) τ (1 − e−h/τ); 63.2 % after τ; costs v τ metres.
Sensors: true value, then limits, noise and whole counts; None where the part gives no answer. Encoder ticks = floor(angle / 2π × N): error mean −1/2 tick, standard deviation 1/√12 = 0.289 tick. Standard error of a mean = σ / √N. Gaussian noise has no outliers: the easy case.
Interface:range_m(), ticks(), set_duty(left, right) as in Programming for Robotics 1 section 7, plus line(), bump(), advance(dt_s), now_s(). A collision undoes the step, stops the wheels and sets the contact.
Drift check: a tyre 1 % too big over 3 m: 0.30 m sideways, 11.5 degrees; the sideways error grows with the square of the distance. A track width of 0.155 m against 0.150 m: a 360 degree turn is 348.4 degrees. The simulator and the formula agree to under 1 mm.
Fidelity: physics step small against the smallest obstacle; control period equal to the real loop period; rays no further apart than 2 atan(r / d); cost = rate × rays × shapes. Delay: approach is smooth if k × delay < 1/e, overshoots above that, unstable above π/2 (from memory).
Next: Programming for Robotics 4, Sensor Code and Filtering
This chapter built the world the rest of the course runs in. The Programming for Robotics 1 robot ran in it unchanged and stopped at the stop line, and the simulator showed what the real world will show too: readings that jitter (5 mm of range noise, a speed that flickers by one tick per pass), a sensor that is late, and a controller that is fine with a gentle gain and dangerous with a sharp one. Programming for Robotics 4 (planned; not yet written) is the answer to the readings. It writes the sensor code properly, for ultrasonic, infrared and line sensors and for encoder counts, and the filters that turn raw readings into usable ones (debouncing, the median for outliers, the moving average and the exponential filter, with their delays measured), calibration code, and the quadrature decoder; and it tests every filter on the simulator of this chapter with the noise turned up and with outliers added, which is where the simulator’s easy Gaussian noise stops being enough.