Driving: Motors and Odometry

Programming for Robotics

Course 1 · Chapter 5 · Driving: Motors and Odometry

Programming for Robotics 4 made the robot’s senses trustworthy. This chapter does the same for its body. The robot of Programming for Robotics 1 section 6 had a one-line idea of driving: a command from −1 to +1 for each wheel, handed to set_duty. A real drive needs much more than that. The motor does not turn at all below some duty (the dead zone), and a sudden step in duty is hard on the gearbox, the battery and the wheels’ grip. A “speed” in metres per second is only meaningful once the speed curve of the motors has been measured. A velocity command (go forward at 0.3 m/s while turning at 1 rad/s) has to become two wheel speeds, and if a wheel cannot go that fast, the answer must still be the same circle, only slower. The encoders then tell where the robot has got to, if the pose is updated correctly, and “drive 1 m” has to be a program that stops where it said it would. Finally, every number in the geometry is a little wrong, and the errors can be measured and removed.

The chapter builds all of that as one module, drive.py, in about 900 lines of standard-library Python (about 730 without the tests), in the order the pieces depend on each other. It uses the formulas of Robotics Fundamentals 3 section 2 (the motor model), Robotics Fundamentals 4 sections 3 to 6 (kinematics, odometry and drift), and the safety rules of Robotics Fundamentals 10 section 7, each tied to the code that implements it. It reuses the pieces of the three previous chapters instead of rewriting them: FixedRateLoop and LoopStats of Programming for Robotics 1 sections 2 and 4, Config of its section 6, the simulator of Programming for Robotics 3, and SlewLimiter, SpeedEstimator, Sensor, Health and LinearCalibration of Programming for Robotics 4 (sections 3, 9, 2, 10 and 8). The simulator is the test bench for the whole chapter, and it is made to disagree with the software on purpose several times, so that calibration has something to find.

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. You need, in the same session or importable, the Config, clamp and Robot code of Programming for Robotics 1 section 6, the loop classes of its sections 2 and 4, the simulator sim.py of Programming for Robotics 3 (its section 14 listing) and the filters.py of Programming for Robotics 4 (its section 12 listing). The blocks without an output, together, are the new module drive.py; section 14 lists it in one piece, and its seventeen tests (section 12) pass. Every random number comes from a random.Random(seed), so your outputs match the ones shown. Many numbers are assumptions chosen for the demonstration, not measurements: the motor time constants of the brake, zero-duty and coast cases (0.03 s, 0.08 s, 0.25 s), the dead zone of 0.12 and the 0.46 m/s top speed of the simulated motor rig, the 1 % bigger tyre and the 155 mm true track width. Measure your own robot before trusting any of them. The two blocks of section 13 (MicroPython for the build robot’s Raspberry Pi Pico, and the main program) are written from memory of the MicroPython documentation and the motor driver’s data sheet, were NOT run on hardware, and were only checked for syntax. The method of section 10 (a derived version of the UMBmark test) is my own derivation, checked in the simulator, not a copy of the published method; it is described as I remember Borenstein and Feng’s idea, so look the original up before relying on its details.

1. The layers, and the conventions that glue them

The module is a stack, each layer using only the ones above it. Each layer is a small class or function with a test, and the simulator replaces everything below the hardware seam of Programming for Robotics 1 section 7:

a Move (drive 1 m, turn 90 degrees, a square) what to do, as a step function | (v, omega) once per pass v +------------------------------------+ | DriveBase | one pass: sense, run the move, check the guards, drive or cut | Guards: watchdog, encoders, stall| +------------------------------------+ | wheel speeds (m/s) ^ tick counts v | wheel_speeds() inverse kinematics Odometry pose from ticks | fraction of full speed ^ v | Motor deadband, ramp, cap SpeedEstimator (Programming for Robotics 4 section 9) | signed duty | v | hardware seam: set_duty(left, right) ticks() PicoHardware, the simulator, or a test rig

Conventions, written once because every sign error in a robot comes from a convention that was assumed and not stated:

  • Axes. The robot’s x axis points forward and its y axis to its left. The pose is (x, y, theta), with theta measured counter-clockwise from the world x axis, so a positive turn rate omega is a counter-clockwise turn (to the left), as in Robotics Fundamentals 4 section 3.
  • Units. Metres, seconds, radians, metres per second. A “duty” is a signed fraction from −1 to +1 of the full battery voltage on the motor; a motor “command” is a signed fraction of the motor’s own top speed. Degrees appear only in printed output.
  • Angles. A heading is wrapped to −π .. +π with wrap_pi, and a heading error is always wrapped after subtracting, so the robot turns the short way. But a total turn is not wrapped: turning twice on the spot is 4π (section 5 shows why a move needs that).
  • Counts. The encoders give signed counts, positive when the wheel turns forward; a wheel that reads backwards is fixed once, in the hardware class, never in the maths (section 13 gives the bench test).

The first block of the module carries these conventions: the imports from the earlier chapters, and the two small helpers that every layer uses.

# drive.py -- the driving code of Programming for Robotics 5 (standard library only) # Needs filters.py of Programming for Robotics 4 for SlewLimiter, SpeedEstimator, Sensor, Health, LinearCalibration, is_number and run_tests. # In one session those names already exist; saved as files, they are imported. import math try: from filters import SlewLimiter, SpeedEstimator, Sensor, Health, LinearCalibration, is_number, run_tests except ImportError: pass def clamp(value, low, high): return max(low, min(high, value)) def wrap_pi(angle): """Wrap an angle in radians to -pi .. +pi.""" return math.atan2(math.sin(angle), math.cos(angle))

The try / except ImportError is there so that the file works in two settings: in one session, where the names from Programming for Robotics 4 already exist, and as a file next to filters.py, where they are imported. It is the only place where the module touches the earlier chapters at import time; the classes that follow take everything else as arguments.

2. The motor: dead zone, ramp, cap, brake and an always-safe stop

A motor command is a fraction from −1 to +1 of the motor’s own top speed. The Motor class turns it into a signed duty with four jobs, each a physical fact and each a few lines.

  • Dead zone. Below some duty the motor does not turn at all (friction and the electrical resistance of the winding and driver must be overcome first; Robotics Fundamentals 3 section 2 has the motor model). A command of 0.05 is not 5 % of the way to anything. The map is duty = deadband + (1 − deadband) × |command|, so that the smallest command that is not zero turns the wheel and the full command is full duty. A command below a small cutoff means stop, not creep.
  • Ramp. The command goes through the SlewLimiter of Programming for Robotics 4 section 3, which limits how fast it may change. A step from 0 to 1 then takes 1 / slew seconds, and a reversal from +1 to −1 takes 2 / slew: the wheels are never asked to reverse in one pass.
  • Cap. A maximum duty, 1.0 normally and 0.25 for the first runs on a real robot (Robotics Fundamentals 10 section 7). The constructor refuses a cap that is below the dead zone: a robot that can never move is a bug to find at construction, not on the floor.
  • Stop. stop() sets the command and duty to zero now, with no ramp and no dependence on any state: it works after a fault, in the middle of a ramp, with a bad value in the command, any number of times. The ramp is for driving. The way out must not be slowed by it.
class Motor: """One motor behind a command from -1 to +1 (a fraction of full speed): ramp, deadband compensation and a duty cap. set(cmd, dt_s) returns the signed duty to send (the sign is the direction). stop() is the always-safe way out.""" def __init__(self, deadband=0.0, slew_per_s=None, max_duty=1.0, cutoff=0.02, brake=False): assert max_duty > deadband, "a duty cap below the deadband would stop the robot from ever moving" self.deadband = deadband # the duty at which the motor really starts to turn (measured: section 3) self.max_duty = max_duty # a cap on |duty|: 0.25 for the first runs on a real robot self.cutoff = cutoff # a command smaller than this means 'stop', not 'creep' self.brake = brake # what stop() asks of the driver: True = brake, False = coast self.ramp = None if slew_per_s is not None: self.ramp = SlewLimiter(slew_per_s) # the slew limiter of Programming for Robotics 4, in command units per second self.ramp.value = 0.0 # start from rest, not from the first command self.cmd = 0.0 # the command after the ramp self.duty = 0.0 # the signed duty last returned def duty_for(self, cmd): """The static map from a command to a signed duty: deadband + (1 - deadband) |cmd|, capped; tiny commands give zero.""" mag = abs(cmd) if mag < self.cutoff: return 0.0 duty = min(self.max_duty, self.deadband + (1.0 - self.deadband) * mag) return duty if cmd > 0 else -duty def set(self, cmd, dt_s): if not is_number(cmd): return self.stop() # a NaN must never reach a motor cmd = clamp(cmd, -1.0, 1.0) if self.ramp is not None: cmd = self.ramp.update(cmd, dt_s) self.cmd = cmd self.duty = self.duty_for(cmd) return self.duty def stop(self): """Zero at once: no ramp, no argument, no state it depends on. Safe in any state, any number of times.""" self.cmd = 0.0 self.duty = 0.0 if self.ramp is not None: self.ramp.value = 0.0 return 0.0

Here is each in turn, with the numbers worked out first. With a dead zone of 0.12, a command of 0.5 gives 0.12 + 0.88 × 0.5 = 0.56. A ramp of 2.0 command units per second takes 1.0 / 2.0 = 0.5 s to reach full duty, which at 20 ms per pass is 25 passes; a reversal from +1 to −1 takes 2.0 / 2.0 = 1.0 s, that is 50 passes.

m = Motor(deadband=0.12) print("command -> duty, deadband 0.12 (duty = 0.12 + 0.88 x |command|; below the cutoff of 0.02: zero)") for cmd in (0.0, 0.01, 0.02, 0.10, 0.50, 1.00, -0.50): print(" cmd %+5.2f -> duty %+.4f" % (cmd, m.duty_for(cmd))) capped = Motor(deadband=0.12, max_duty=0.25) print("with a duty cap of 0.25: cmd 1.00 -> duty %.2f, the fastest command that is not clipped is %.4f (= %.0f %% of full speed)" % (capped.duty_for(1.0), (0.25 - 0.12) / 0.88, 100 * (0.25 - 0.12) / 0.88)) ramped = Motor(deadband=0.12, slew_per_s=2.0) # 2.0 command units per second: a full step takes 0.5 s print("ramp, slew 2.0 per second, command jumps from 0 to 1 at pass 0 (one pass = 20 ms):") first_full = None for k in range(1, 31): duty = ramped.set(1.0, 0.02) if k in (1, 5, 10, 15, 20, 25): print(" pass %2d (t = %.2f s): command %.3f duty %.3f" % (k, k * 0.02, ramped.cmd, duty)) if first_full is None and duty >= 1.0: first_full = k print("full duty first reached at pass %d = %.2f s; predicted delta / slew = 1.0 / 2.0 = %.2f s" % (first_full, first_full * 0.02, 1.0 / 2.0)) passes = 0 while ramped.cmd > -1.0 + 1e-9: ramped.set(-1.0, 0.02) passes += 1 print("reversing from +1 to -1 takes %d passes = %.2f s (2.0 / 2.0 = 1.00 s)" % (passes, passes * 0.02)) m = Motor(deadband=0.12, slew_per_s=2.0) for _ in range(10): m.set(1.0, 0.02) before = m.duty print("stop(): duty %.3f -> %.3f, command %.1f; again: %.1f; a NaN or None command: %.1f, %.1f" % (before, m.stop(), m.cmd, m.stop(), m.set(float("nan"), 0.02), m.set(None, 0.02)))
command -> duty, deadband 0.12 (duty = 0.12 + 0.88 x |command|; below the cutoff of 0.02: zero) cmd +0.00 -> duty +0.0000 cmd +0.01 -> duty +0.0000 cmd +0.02 -> duty +0.1376 cmd +0.10 -> duty +0.2080 cmd +0.50 -> duty +0.5600 cmd +1.00 -> duty +1.0000 cmd -0.50 -> duty -0.5600 with a duty cap of 0.25: cmd 1.00 -> duty 0.25, the fastest command that is not clipped is 0.1477 (= 15 % of full speed) ramp, slew 2.0 per second, command jumps from 0 to 1 at pass 0 (one pass = 20 ms): pass 1 (t = 0.02 s): command 0.040 duty 0.155 pass 5 (t = 0.10 s): command 0.200 duty 0.296 pass 10 (t = 0.20 s): command 0.400 duty 0.472 pass 15 (t = 0.30 s): command 0.600 duty 0.648 pass 20 (t = 0.40 s): command 0.800 duty 0.824 pass 25 (t = 0.50 s): command 1.000 duty 1.000 full duty first reached at pass 25 = 0.50 s; predicted delta / slew = 1.0 / 2.0 = 0.50 s reversing from +1 to -1 takes 50 passes = 1.00 s (2.0 / 2.0 = 1.00 s) stop(): duty 0.472 -> 0.000, command 0.0; again: 0.0; a NaN or None command: 0.0, 0.0

Three things in the output are worth reading. The command 0.01 gives a duty of exactly 0: below the cutoff of 0.02, the answer is “stop”, not a duty that would stall the motor and heat it. With a cap of 0.25 the fastest command that is not clipped is (0.25 − 0.12) / 0.88 = 0.1477, so the cap on a robot with this dead zone limits the speed to 15 % of its top speed, not to 25 %: a duty cap is not a speed cap, a point section 3 returns to. And the ramp’s measured time matches delta / slew to the pass. The ramp works on the command, so the duty jumps at the first pass from 0 to the dead zone plus a little (0.155 in the output): a ramp cannot soften that jump, which is physics, and it is the reason a very gentle ramp on a motor with a big dead zone still gives a visible jolt.

Brake or coast?

When the duty goes to zero, the driver either leaves the motor terminals open, so the motor free-wheels (coast), or shorts them together, so the motor’s own back-voltage drives a current that opposes the motion (brake). Both end at zero speed. They differ in how long it takes. To a good approximation the wheel speed falls like a first-order lag, v(t) = v0 e−t/τ, so the distance covered until it stops is the integral, v0 τ: stopping distance = speed × time constant. The time constant is short for a brake (the driver does the work) and long for a coast (only friction does). The simulator’s own motor is the 0.08 s lag of Programming for Robotics 3 section 5. The two other values here are assumptions, in the order of magnitude a small geared motor gives, not measurements; measuring them is a bench test in section 13.

def stopping_distance(tau_s, v_mps=0.3): """Drive at v_mps, then command zero: how far the robot rolls before it stops. A first-order lag gives exactly v x tau.""" sim = Simulator(World(10.0, 4.0), params=RobotParams(tau_s=tau_s), seed=1, pose=(1.0, 2.0, 0.0)) sim.set_duty(v_mps / 0.40, v_mps / 0.40) for _ in range(250): sim.advance(0.02) x0 = sim.pose()[0] sim.set_duty(0.0, 0.0) for _ in range(500): sim.advance(0.02) return sim.pose()[0] - x0 print("stopping from 0.3 m/s: distance = v x tau (a lag of time constant tau) [the three tau values are ASSUMPTIONS, not measurements]") print(" mode assumed tau predicted v x tau simulated") for name, tau in (("brake (motor shorted)", 0.03), ("zero duty, as in the simulator", 0.08), ("coast (free-wheeling)", 0.25)): print(" %-32s %5.2f s %6.1f mm %6.1f mm" % (name, tau, 0.3 * tau * 1000, stopping_distance(tau) * 1000))
stopping from 0.3 m/s: distance = v x tau (a lag of time constant tau) [the three tau values are ASSUMPTIONS, not measurements] mode assumed tau predicted v x tau simulated brake (motor shorted) 0.03 s 9.0 mm 9.0 mm zero duty, as in the simulator 0.08 s 24.0 mm 24.0 mm coast (free-wheeling) 0.25 s 75.0 mm 75.0 mm

The simulated distances equal the predictions to the millimetre because the simulator’s motor is that same first-order lag; this proves the code and the formula agree, not that your motor behaves so. The lesson that survives is the ratio: with these assumed values, a coasting robot at 0.3 m/s rolls 75 mm, eight times as far as a braking one (9 mm), and the stop distance enters every safety budget (section 11). The class keeps this as a flag, brake, which DriveBase passes on to the hardware when both duties are zero: on this robot, braking is the better default for a stop, coasting the better choice at the end of a smooth move where a jerk is not wanted. The hardware class decides what “brake” means electrically (section 13).

3. Measuring the speed curve

The Motor class works in fractions of top speed, and top speed is something the software only believes (0.40 m/s in Config, copied from a design calculation). To drive in metres per second the robot needs the real map from duty to wheel speed, and the way to get it is the way Programming for Robotics 4 section 8 calibrated a sensor: apply known inputs, measure the outputs, fit a line. The input is a duty, the output is a wheel speed from the encoder counts, and the line is speed = k × (duty − deadband) above the dead zone. LinearCalibration.fit gives gain and offset for speed = gain × duty + offset; then k = gain and deadband = −offset / gain. Points where the wheel did not move are left out of the fit, because their speed (zero) is not on the line.

class SpeedCurve: """The open-loop model of one motor: speed = k (duty - deadband) above the deadband, zero below it.""" def __init__(self, k_mps, deadband): self.k = k_mps # m/s per unit of duty above the deadband (the slope) self.deadband = deadband @property def max_speed_mps(self): return self.k * (1.0 - self.deadband) # the speed at duty 1.0 def speed_at(self, duty): mag = abs(duty) v = 0.0 if mag <= self.deadband else self.k * (mag - self.deadband) return v if duty >= 0 else -v def duty_for(self, speed_mps): if speed_mps == 0.0: return 0.0 mag = self.deadband + abs(speed_mps) / self.k return mag if speed_mps > 0 else -mag def measure_speed_curves(make_hw, mpt_left, mpt_right, duties, settle_s=1.0, measure_s=1.0, dt_s=0.02, min_mps=0.01): """Sweep the duty, each value on a fresh robot: let the speed settle, then measure it from the encoder counts. Returns (points, (left curve, right curve)); a point is (duty, left m/s, right m/s). Wheels that did not move are left out of the fit.""" points = [] for duty in duties: hw = make_hw() hw.set_duty(duty, duty) for _ in range(int(round(settle_s / dt_s))): hw.advance(dt_s) before = hw.ticks() for _ in range(int(round(measure_s / dt_s))): hw.advance(dt_s) after = hw.ticks() points.append((duty, (after[0] - before[0]) * mpt_left / measure_s, (after[1] - before[1]) * mpt_right / measure_s)) curves = [] for side in (1, 2): moving = [(p[0], p[side]) for p in points if p[side] >= min_mps] fit = LinearCalibration.fit([d for d, _ in moving], [v for _, v in moving]) # speed = gain x duty + offset curves.append(SpeedCurve(fit.gain, -fit.offset / fit.gain)) return points, tuple(curves) class RealMotors: """Test rig: motors that are not ideal. Per wheel, a deadband (no motion below it) and a gain on the speed above it. set_duty() maps the duty the way a real motor and driver would; everything else passes straight through.""" def __init__(self, hw, deadband=(0.0, 0.0), gain=(1.0, 1.0)): self.hw = hw self.deadband = deadband self.gain = gain def set_duty(self, left, right): out = [] for duty, db, g in zip((left, right), self.deadband, self.gain): mag = abs(duty) effective = 0.0 if mag <= db else g * (mag - db) / (1.0 - db) out.append(math.copysign(effective, duty)) self.hw.set_duty(out[0], out[1]) def __getattr__(self, name): return getattr(self.hw, name)

SpeedCurve is the open-loop model of one motor, and duty_for is its inverse: to get a speed, take the dead zone and add speed / k. RealMotors is a test rig: it wraps a simulator and makes the motors not what the software believes, with a dead zone and a gain per wheel, so that the sweep has something to find. It uses the idea that the same set_duty / ticks seam from Programming for Robotics 1 section 7 can sit between the program and a world that has been made to misbehave.

mpt = Config.METRES_PER_TICK # 0.340 mm per tick, the build robot as the software believes it (Programming for Robotics 1 section 8) print("believed: %.4f mm per tick, %.2f m/s at duty 1.0, no deadband" % (mpt * 1000, Config.MAX_SPEED_MPS)) def make_motors(): """A fresh robot whose motors are NOT the ones the software believes in: a deadband of 0.12 and 0.46 m/s at full duty (assumptions).""" sim = Simulator(World(8.0, 4.0), params=RobotParams(max_speed_mps=0.46), seed=1, pose=(1.0, 2.0, 0.0)) return RealMotors(sim, deadband=(0.12, 0.12)) sweep = (0.05, 0.10, 0.15, 0.20, 0.30, 0.40, 0.50, 0.60, 0.80, 1.00) points, (left_curve, right_curve) = measure_speed_curves(make_motors, mpt, mpt, sweep) print(" duty left (m/s) right (m/s)") for duty, left, right in points: print(" %.2f %.4f %.4f%s" % (duty, left, right, " <- not moving: left out of the fit" if left < 0.01 else "")) print("fit, left: k = %.4f m/s per unit duty, deadband = %.4f, full-duty speed k (1 - deadband) = %.4f m/s" % (left_curve.k, left_curve.deadband, left_curve.max_speed_mps)) print("fit, right: k = %.4f m/s per unit duty, deadband = %.4f, full-duty speed k (1 - deadband) = %.4f m/s" % (right_curve.k, right_curve.deadband, right_curve.max_speed_mps)) print("the truth built into the rig: k = 0.46 / (1 - 0.12) = %.4f, deadband 0.12, full-duty speed 0.4600" % (0.46 / 0.88)) want = 0.30 duty = left_curve.duty_for(want) print("duty for %.2f m/s from the measured curve: %.4f + %.2f / %.4f = %.4f; from the believed curve (no deadband, 0.40 m/s at 1.0): %.4f" % (want, left_curve.deadband, want, left_curve.k, duty, want / Config.MAX_SPEED_MPS)) for name, d in (("measured curve", duty), ("believed curve", want / Config.MAX_SPEED_MPS)): check = make_motors() check.set_duty(d, d) for _ in range(100): check.advance(0.02) t0 = check.ticks()[0] for _ in range(50): check.advance(0.02) speed = (check.ticks()[0] - t0) * mpt / 1.0 print(" %s: duty %.4f gives %.4f m/s (error %+.1f %%)" % (name, d, speed, 100 * (speed - want) / want)) print("a first-run cap of 0.25 duty allows at most k (0.25 - deadband) = %.4f m/s on this robot" % left_curve.speed_at(0.25))
believed: 0.3403 mm per tick, 0.40 m/s at duty 1.0, no deadband duty left (m/s) right (m/s) 0.05 0.0000 0.0000 <- not moving: left out of the fit 0.10 0.0000 0.0000 <- not moving: left out of the fit 0.15 0.0157 0.0157 0.20 0.0415 0.0415 0.30 0.0939 0.0939 0.40 0.1463 0.1463 0.50 0.1988 0.1988 0.60 0.2508 0.2508 0.80 0.3557 0.3557 1.00 0.4601 0.4601 fit, left: k = 0.5231 m/s per unit duty, deadband = 0.1203, full-duty speed k (1 - deadband) = 0.4602 m/s fit, right: k = 0.5231 m/s per unit duty, deadband = 0.1203, full-duty speed k (1 - deadband) = 0.4602 m/s the truth built into the rig: k = 0.46 / (1 - 0.12) = 0.5227, deadband 0.12, full-duty speed 0.4600 duty for 0.30 m/s from the measured curve: 0.1203 + 0.30 / 0.5231 = 0.6938; from the believed curve (no deadband, 0.40 m/s at 1.0): 0.7500 measured curve: duty 0.6938 gives 0.2998 m/s (error -0.1 %) believed curve: duty 0.7500 gives 0.3294 m/s (error +9.8 %) a first-run cap of 0.25 duty allows at most k (0.25 - deadband) = 0.0678 m/s on this robot

The sweep finds a dead zone of 0.1203 and a slope of 0.5231 m/s per unit duty, against the rig’s true 0.12 and 0.5227; the full-duty speed k (1 − deadband) = 0.4602 m/s is the 0.46 m/s the rig was given. The two points inside the dead zone are marked and left out. Then the practical point: for 0.30 m/s the measured curve asks for a duty of 0.6938 and the robot does 0.2998 m/s; the believed curve asks for 0.75 and the robot does 0.3294 m/s, 9.8 % too fast. That error is a wrong map, not noise, and nothing in a loop that never looks at the speed would find it. It also settles what the duty cap does: at a duty cap of 0.25 this robot cannot go faster than 0.0678 m/s. Robotics Fundamentals 10 section 7 predicted about 0.16 m/s at a 25 % cap for the build robot, from its own motor model, and the plain simulator gives 0.099 m/s (it takes duty 1.0 as 0.40 m/s with no dead zone). Three different answers to “how fast at 25 % duty?” mean that three different motors were assumed, and the real one has to be measured: the first floor run is the place to find out, with the wheels off the ground (section 13).

4. From a velocity command to wheel speeds

The controller of later chapters says “go forward at v and turn at omega”. A differential drive turns that into two wheel speeds with the inverse kinematics of Robotics Fundamentals 4 section 4: the left wheel goes at v − omega × track / 2 and the right wheel at v + omega × track / 2. Going the other way, the forward kinematics of its section 3 turn two measured wheel speeds into (v, omega). The code adds the one thing the formulas leave out: what to do when a wheel cannot go that fast.

If the faster wheel would need more than the motors can give, there are two answers. Clip that wheel, and the robot turns on a tighter curve than was asked for (the ratio of the wheel speeds has changed, and the turning radius is v / omega, the ratio of the two). Or scale both wheels by the same factor, and the radius survives: the robot goes slower along the same circle. The second is almost always what is wanted, and wheel_speeds returns the factor so that the caller (and the log) can see that the limit was hit. Geometry holds the three numbers that calibration will correct (metres per tick for each wheel, and the track width) in one place, so the corrected values can be dropped in without touching anything else.

class Geometry: """The robot's dimensions as the SOFTWARE believes them: the numbers that calibration corrects.""" def __init__(self, mpt_left, mpt_right, track_m, max_speed_mps): self.mpt_left = mpt_left # metres of ground per encoder tick, per wheel self.mpt_right = mpt_right self.track_m = track_m # the effective distance between the wheel contact points self.max_speed_mps = max_speed_mps # the believed wheel speed at duty 1.0 @classmethod def from_config(cls, cfg): return cls(cfg.METRES_PER_TICK, cfg.METRES_PER_TICK, cfg.TRACK_WIDTH_M, cfg.MAX_SPEED_MPS) def scaled(self, left=1.0, right=1.0, track=1.0): """A copy with each number multiplied by a correction factor.""" return Geometry(self.mpt_left * left, self.mpt_right * right, self.track_m * track, self.max_speed_mps) def __repr__(self): return "Geometry(mpt_left=%.6f mm, mpt_right=%.6f mm, track=%.2f mm)" % (self.mpt_left * 1e3, self.mpt_right * 1e3, self.track_m * 1e3) def wheel_speeds(v_mps, omega_rps, track_m, max_wheel_mps=None): """Inverse kinematics (Robotics Fundamentals 4 section 4): returns (left, right, scale). If a wheel would need more than max_wheel_mps, BOTH are scaled by the same factor, so the turning radius survives.""" half = 0.5 * omega_rps * track_m left, right = v_mps - half, v_mps + half scale = 1.0 if max_wheel_mps is not None: top = max(abs(left), abs(right)) if top > max_wheel_mps: scale = max_wheel_mps / top left, right = left * scale, right * scale return left, right, scale def body_speeds(left_mps, right_mps, track_m): """Forward kinematics (Robotics Fundamentals 4 section 3): the wheel speeds to (v, omega), counter-clockwise positive.""" return 0.5 * (left_mps + right_mps), (right_mps - left_mps) / track_m

A check with the numbers of Robotics Fundamentals 4 section 4 (track 0.150 m): a velocity command of (0.5 m/s, 3.0 rad/s) needs wheels at 0.275 and 0.725 m/s, and the turning radius is 0.5 / 3.0 = 0.1667 m. If the faster wheel is limited to 0.6 m/s, both are scaled by 0.6 / 0.725 = 0.8276, giving 0.2276 and 0.6 m/s, a forward speed of 0.4138 m/s, a turn rate of 2.4828 rad/s and the same 0.1667 m radius. Clipping only the fast wheel gives wheels of 0.275 and 0.6, v = 0.4375, omega = 2.1667 and a radius of 0.2019 m: a 21 % wider circle, which a robot in a corridor would feel as a wall.

geom = Geometry.from_config(Config) # the build robot: 0.340 mm per tick, track 150 mm, 0.40 m/s track = geom.track_m print("(v, omega) left right scale back to (v, omega) turning radius v / omega") for v, w, top in ((0.40, 2.0, None), (0.0, 3.0, None), (0.5, 3.0, None), (0.5, 3.0, 0.6)): left, right, scale = wheel_speeds(v, w, track, top) v2, w2 = body_speeds(left, right, track) limit = "no limit" if top is None else "limit %.1f" % top print("(%.2f, %.1f) %+.4f %+.4f %.4f (%.4f, %.4f) R %-8s %s" % (v, w, left, right, scale, v2, w2, "%.4f m" % (v2 / w2) if w2 else "-", limit)) left, right, _ = wheel_speeds(0.5, 3.0, track) # (0.275, 0.725): the right wheel is too fast for a limit of 0.6 v2, w2 = body_speeds(left, 0.6, track) # clip the right wheel alone, leave the left one print("the wrong way to obey the limit, clipping only the wheel that is too fast: wheels (%.3f, 0.600) -> v %.4f, omega %.4f, R %.4f m (instead of 0.1667)" % (left, v2, w2, v2 / w2))
(v, omega) left right scale back to (v, omega) turning radius v / omega (0.40, 2.0) +0.2500 +0.5500 1.0000 (0.4000, 2.0000) R 0.2000 m no limit (0.00, 3.0) -0.2250 +0.2250 1.0000 (0.0000, 3.0000) R 0.0000 m no limit (0.50, 3.0) +0.2750 +0.7250 1.0000 (0.5000, 3.0000) R 0.1667 m no limit (0.50, 3.0) +0.2276 +0.6000 0.8276 (0.4138, 2.4828) R 0.1667 m limit 0.6 the wrong way to obey the limit, clipping only the wheel that is too fast: wheels (0.275, 0.600) -> v 0.4375, omega 2.1667, R 0.2019 m (instead of 0.1667)

5. Odometry: the pose from the encoder ticks

The pose (x, y, theta) is updated from the distance each wheel has rolled since the last update, dL and dR (ticks × metres per tick). The centre of the robot moves ds = (dL + dR) / 2 and the heading changes by dtheta = (dR − dL) / track (Robotics Fundamentals 4 section 5). What is left is where, in that step, the robot moved: along the old heading, along the new one, or along the arc it really drove. There are three choices and the code offers the last two.

  • Old heading. x += ds cos(theta). Exact for a straight line, wrong for any turn: for large steps, very wrong.
  • Midpoint. Use the heading half way through the step, theta + dtheta / 2. Cheap and nearly right.
  • Arc. Drive a circle of radius R = ds / dtheta: x += R (sin(theta + dtheta) − sin(theta)), y −= R (cos(theta + dtheta) − cos(theta)). Exact when both wheels ran at constant speed during the step. It needs a guard for a straight line (dtheta near 0, where R is infinite), where it falls back on the midpoint formula, which is exact there.

The class takes unwrapped tick totals (the sum of the signed changes of the encoder counters, already free of the counter wrap-around of Programming for Robotics 4 section 9, which the SpeedEstimator keeps) and, besides the pose, keeps two running sums that moves will need: the distance driven and the total angle turned, which is not wrapped.

def pose_step(x, y, theta, ds, dtheta, method="arc"): """Move a pose by the centre travel ds and the turn dtheta: the update of Robotics Fundamentals 4 section 5.""" if method == "midpoint" or abs(dtheta) < 1e-9: x += ds * math.cos(theta + dtheta / 2) # the heading half way through (and 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 Odometry: """Dead reckoning from the two wheels' tick totals (already unwrapped): x forward, y left, counter-clockwise positive.""" def __init__(self, geom, pose=(0.0, 0.0, 0.0), method="arc"): self.geom = geom self.x, self.y, self.theta = pose self.method = method self.last = None # the tick totals at the previous update self.distance_m = 0.0 # the signed distance the centre has travelled, summed self.turned_rad = 0.0 # the total turn, NOT wrapped: two turns on the spot are 12.57 rad def update(self, left_total, right_total): if self.last is None: # the first reading only sets the reference self.last = (left_total, right_total) return 0.0, 0.0 d_left = (left_total - self.last[0]) * self.geom.mpt_left d_right = (right_total - self.last[1]) * self.geom.mpt_right self.last = (left_total, right_total) return self.step(d_left, d_right) def step(self, d_left, d_right): """Apply the distances the two wheels rolled since the last update.""" ds = 0.5 * (d_left + d_right) dtheta = (d_right - d_left) / self.geom.track_m self.x, self.y, self.theta = pose_step(self.x, self.y, self.theta, ds, dtheta, self.method) self.distance_m += ds self.turned_rad += dtheta return ds, dtheta @property def pose(self): return (self.x, self.y, self.theta)

Robotics Fundamentals 4 section 5 had a worked example: 1200 ticks on the left wheel and 1500 on the right, in one deliberately long step, on the build robot (0.340 mm per tick, track 0.150 m). Running it through the class reproduces the numbers, and shows how far the two methods differ and why the loop period matters:

# Robotics Fundamentals 4 section 5: 1200 and 1500 ticks in one (deliberately long) step, starting at the origin facing +x d_left, d_right = 1200 * geom.mpt_left, 1500 * geom.mpt_right ds, dtheta = 0.5 * (d_left + d_right), (d_right - d_left) / geom.track_m print("dL = %.4f m, dR = %.4f m; ds = %.4f m, dtheta = %.4f rad = %.1f deg, R = ds / dtheta = %.3f m" % (d_left, d_right, ds, dtheta, math.degrees(dtheta), ds / dtheta)) results = {} for method in ("arc", "midpoint"): odo = Odometry(geom, method=method) odo.update(0, 0) # the first call only sets the reference odo.update(1200, 1500) results[method] = (odo.x, odo.y) print(" %-9s x = %.4f m y = %.4f m heading = %.1f deg" % (method, odo.x, odo.y, math.degrees(odo.theta))) print(" %-9s x = %.4f m y = %.4f m (the old heading for the whole step: far too crude)" % ("old-theta", ds, 0.0)) gap = math.hypot(results["arc"][0] - results["midpoint"][0], results["arc"][1] - results["midpoint"][1]) print("midpoint is %.1f mm from the arc answer; the formula ds x dtheta^2 / 24 = %.4f x %.4f^2 / 24 = %.1f mm" % (gap * 1000, ds, dtheta, ds * dtheta ** 2 / 24 * 1000)) print() print("one step of a robot driving at v = 0.3 m/s and turning at omega = 1.5 rad/s (radius 0.2 m), for different loop periods:") print(" dt (s) dtheta (rad) ds (m) arc chord (m) midpoint minus arc (mm) formula ds dtheta^2/24 (mm)") for dt in (0.02, 0.1, 0.5, 2.0): ds_, dth_ = 0.3 * dt, 1.5 * dt xa, ya, _ = pose_step(0.0, 0.0, 0.0, ds_, dth_, "arc") xm, ym, _ = pose_step(0.0, 0.0, 0.0, ds_, dth_, "midpoint") print(" %5.2f %8.3f %.4f %.6f %12.4f %12.4f" % (dt, dth_, ds_, math.hypot(xa, ya), math.hypot(xa - xm, ya - ym) * 1000, ds_ * dth_ ** 2 / 24 * 1000)) print("for comparison, one encoder tick is %.3f mm of wheel travel" % (geom.mpt_left * 1000)) print() odo = Odometry(geom) odo.update(0, 0) quarter = (math.pi / 2) * geom.track_m / 2 / geom.mpt_left # ticks per wheel for a quarter turn on the spot (wheels in opposite directions) print("spinning on the spot, a quarter turn (%.1f ticks per wheel) at a time:" % quarter) print(" quarter theta (deg) turned_rad (deg) theta without a wrap (deg)") raw = 0.0 for k in range(1, 9): odo.step(-quarter * geom.mpt_left, quarter * geom.mpt_right) raw += math.pi / 2 print(" %5d %9.1f %9.1f %9.1f" % (k, round(math.degrees(odo.theta), 6) + 0.0, math.degrees(odo.turned_rad), math.degrees(raw)))
dL = 0.4084 m, dR = 0.5105 m; ds = 0.4595 m, dtheta = 0.6807 rad = 39.0 deg, R = ds / dtheta = 0.675 m arc x = 0.4248 m y = 0.1504 m heading = 39.0 deg midpoint x = 0.4331 m y = 0.1534 m heading = 39.0 deg old-theta x = 0.4595 m y = 0.0000 m (the old heading for the whole step: far too crude) midpoint is 8.8 mm from the arc answer; the formula ds x dtheta^2 / 24 = 0.4595 x 0.6807^2 / 24 = 8.9 mm one step of a robot driving at v = 0.3 m/s and turning at omega = 1.5 rad/s (radius 0.2 m), for different loop periods: dt (s) dtheta (rad) ds (m) arc chord (m) midpoint minus arc (mm) formula ds dtheta^2/24 (mm) 0.02 0.030 0.0060 0.006000 0.0002 0.0002 0.10 0.150 0.0300 0.029972 0.0281 0.0281 0.50 0.750 0.1500 0.146509 3.4910 3.5156 2.00 3.000 0.6000 0.398998 201.0020 225.0000 for comparison, one encoder tick is 0.340 mm of wheel travel spinning on the spot, a quarter turn (346.2 ticks per wheel) at a time: quarter theta (deg) turned_rad (deg) theta without a wrap (deg) 1 90.0 90.0 90.0 2 180.0 180.0 180.0 3 -90.0 270.0 270.0 4 0.0 360.0 360.0 5 90.0 450.0 450.0 6 180.0 540.0 540.0 7 -90.0 630.0 630.0 8 0.0 720.0 720.0

Four findings. First, the two methods differ by 8.8 mm in that one long step; the leading term of the difference, ds × dtheta2 / 24, predicts 8.9 mm. Second, the second table shows why nobody notices at the usual loop period: at 20 ms the midpoint is 0.0002 mm from the arc, a thousandth of one encoder tick (0.340 mm); at 100 ms 0.03 mm; at 0.5 s 3.5 mm; at 2 s, with the robot turning 3 rad per step, the formula (225 mm) is already off from the true 201 mm because the heading change is not small any more. So: at a loop period of 20 ms either method is exact to well below the encoder’s resolution, and the choice matters only when something makes the loop slow, a point for the loop-statistics of Programming for Robotics 1 section 2. Third, the old-heading method puts the robot at (0.4595, 0) instead of (0.4248, 0.1504): 150 mm off in y in one step. Fourth, the last table is why turned_rad is not wrapped: theta comes back to −90 degrees, 0, 90 as the robot spins, which is right for a heading and useless for the question “how far have I turned?” asked by a move that was told to turn 720 degrees.

6. DriveBase: one pass of the loop

DriveBase puts the layers together. Its contract is small. The caller says set_velocity(v, omega) (metres per second and radians per second) and calls update(dt) once per pass of the loop of Programming for Robotics 1 section 4; the base reads the encoders, keeps the pose and the wheel speeds up to date, and sends duties to the motors. The order inside one pass is fixed and is the point of the class:

  1. Sense. Read the ticks. A read that raises OSError is not a crash but a missing reading, kept as None (Programming for Robotics 2 section 11 for why buses fail). Otherwise update the two SpeedEstimators (the filtered wheel speeds) and the odometry.
  2. Run the move, if there is one. A Move (section 7) is asked for its (v, omega), which counts as a command.
  3. Check the guards (section 11). A guard may say hold (stand still for now: not latched) or fault (stand still until someone clears it: latched).
  4. Drive or cut. With a fault or a hold, the motors are cut. Otherwise the wanted (v, omega) go through wheel_speeds with the limit of the slower wheel, each wheel’s speed is divided by its own top speed to give a command, and the two Motors turn that into duty.

The guard comes before the drive, and the cut is the same stop of section 2: nothing a guard needs from the rest of the pass can be missing, because it only reads what the sense step stored. A nice property follows: the pass can be tested without any hardware, with a list of scripted tick counts.

class DriveBase: """The robot's body: set_velocity(v, omega) in, duty cycles out, a pose estimate and wheel speeds kept up to date. Call update(dt_s) once per pass of the loop. Guards (section 11) watch for trouble; a Move (section 7) can be started.""" def __init__(self, hw, geom, curves=None, max_duty=1.0, slew_per_s=4.0, brake=False, window=5, alpha=0.5): self.hw = hw # the three methods of Programming for Robotics 1 section 7: ticks(), set_duty() self.geom = geom if curves is None: # nothing measured yet: duty 1.0 is the believed full speed, no deadband believed = SpeedCurve(geom.max_speed_mps, 0.0) curves = (believed, believed) self.curves = curves self.left = Motor(curves[0].deadband, slew_per_s, max_duty, brake=brake) self.right = Motor(curves[1].deadband, slew_per_s, max_duty, brake=brake) self.max_wheel_mps = min(c.speed_at(max_duty) for c in curves) # what the slower wheel can do at the duty cap self.est_l = SpeedEstimator(geom.mpt_left, window, alpha) self.est_r = SpeedEstimator(geom.mpt_right, window, alpha) self.odom = Odometry(geom) self.t_s = 0.0 # robot time: the sum of the measured dt values self.target = (0.0, 0.0) # the wanted (v, omega) self.last_cmd_t = None # when set_velocity was last called self.move = None self.guards = [] # objects with check(base, dt_s); creating one switches it on self.fault = None # a latched fault: the motors stay off until clear_fault() self.hold = None # a reason to stand still for now (not latched) self.raw_ticks = (None, None) # None: the read failed this pass self.wheel_mps = (0.0, 0.0) # filtered wheel speeds from the encoders self.wanted_mps = (0.0, 0.0) # what the motors were last told, after the ramp self.scale = 1.0 # < 1 when the wheel-speed limit made set_velocity scale both wheels self.send_ok = True self.send_errors = 0 @property def pose(self): return self.odom.pose @property def v_mps(self): return 0.5 * (self.wheel_mps[0] + self.wheel_mps[1]) @property def omega_rps(self): return (self.wheel_mps[1] - self.wheel_mps[0]) / self.geom.track_m def set_velocity(self, v_mps, omega_rps): """The one command: forward speed in m/s, turn rate in rad/s (counter-clockwise positive). Call it every pass.""" if not (is_number(v_mps) and is_number(omega_rps)): self.target = (0.0, 0.0) # garbage in, stop out return self.target = (v_mps, omega_rps) self.last_cmd_t = self.t_s def start(self, move): self.move = move def clear_fault(self): """The deliberate act that lets a latched fault go. The robot does not move until the next set_velocity.""" self.fault = None self.target = (0.0, 0.0) self.last_cmd_t = None def stop(self): """Always safe: cancel the move, forget the command, zero both motors now. Returns True if the hardware took it.""" self.move = None self.target = (0.0, 0.0) self._cut() return self.send_ok def update(self, dt_s): self.t_s += dt_s self._sense() if self.move is not None and self.fault is None: v, w = self.move.update(self, dt_s) self.set_velocity(v, w) # the move is the commander: it counts as a command if self.move.done: self.move = None self._check_guards(dt_s) if self.fault is not None or self.hold is not None: self._cut() return self._drive(dt_s) # ---- the parts of one pass ---- def _sense(self): try: self.raw_ticks = tuple(self.hw.ticks()) except OSError: # a bus error: no reading this pass, not a crash self.raw_ticks = (None, None) left, right = self.raw_ticks if left is not None and right is not None: self.wheel_mps = (self.est_l.update(left, self.t_s), self.est_r.update(right, self.t_s)) self.odom.update(self.est_l.total, self.est_r.total) def _check_guards(self, dt_s): self.hold = None for guard in self.guards: reason = guard.check(self, dt_s) if reason is None: continue if guard.latching: if self.fault is None: self.fault = reason # the first cause wins, and stays until clear_fault() else: self.hold = reason def _drive(self, dt_s): v, w = self.target left, right, self.scale = wheel_speeds(v, w, self.geom.track_m, self.max_wheel_mps) left_duty = self.left.set(left / self.curves[0].max_speed_mps, dt_s) right_duty = self.right.set(right / self.curves[1].max_speed_mps, dt_s) self.wanted_mps = (self.left.cmd * self.curves[0].max_speed_mps, self.right.cmd * self.curves[1].max_speed_mps) self._send(left_duty, right_duty) def _cut(self): if self.fault is not None: self.move = None self.target = (0.0, 0.0) self.left.stop() self.right.stop() self.wanted_mps = (0.0, 0.0) self._send(0.0, 0.0) def _send(self, left_duty, right_duty): try: self.hw.set_duty(left_duty, right_duty) self.send_ok = True except OSError: self.send_ok = False self.send_errors += 1 set_brake = getattr(self.hw, "set_brake", None) # an optional extra method of the hardware object if set_brake is not None and left_duty == 0.0 and right_duty == 0.0: set_brake(self.left.brake)

Five points about the code. set_velocity refuses garbage: a NaN or None for either number becomes (0, 0), because a NaN must never reach a motor. The motors’ commands are the wheel speeds divided by each wheel’s own measured top speed, so a motor that is weaker on one side is already compensated, once the curves of section 3 are given. max_wheel_mps is the speed of the slower wheel at the duty cap, so that scaling by wheel_speeds keeps the radius even when only one motor is the limit. A Move is a commander like any other: it calls set_velocity through the base, so the watchdog of section 11 sees it as a command. And stop() returns whether the hardware accepted the duty, and never raises.

Driving the base at six commands, each held for 3 s on the simulator of Programming for Robotics 3 (the plain simulator, whose motors are as believed), shows the whole pipeline at once:

def hold_velocity(sim, base, v, w, seconds=3.0): """Command (v, omega) on every pass for a while, with the loop of Programming for Robotics 1 section 4: its 'sleep' lets the simulated world move.""" loop = FixedRateLoop(0.02, sim.now_s, sim.advance) def step(dt_s): base.set_velocity(v, w) base.update(dt_s) loop.run(step, int(round(seconds / 0.02))) return loop print(" command (v, omega) wheels wanted (L, R) scale duty (L, R) wheels measured (L, R) measured (v, omega) true omega") for v, w in ((0.30, 0.0), (0.20, 0.8), (0.0, 2.0), (-0.20, 1.0), (0.30, 2.0), (0.40, 3.0)): sim = Simulator(World(10.0, 10.0), seed=1, pose=(5.0, 5.0, 0.0)) base = DriveBase(sim, geom) loop = hold_velocity(sim, base, v, w) wl, wr, _ = wheel_speeds(v, w, geom.track_m, base.max_wheel_mps) true_l, true_r = sim.model.true_speeds_mps() print(" (%+.2f, %+.1f) (%+.4f, %+.4f) %.4f (%+.3f, %+.3f) (%+.4f, %+.4f) (%+.4f, %+.4f) %+.4f" % (v, w, wl, wr, base.scale, base.left.duty, base.right.duty, base.wheel_mps[0], base.wheel_mps[1], base.v_mps, base.omega_rps, (true_r - true_l) / geom.track_m)) print("the last pass of the loop: period %.3f s, measured mean dt %.5f s, %d overruns" % (loop.period_s, loop.stats.mean_s, loop.overruns))
command (v, omega) wheels wanted (L, R) scale duty (L, R) wheels measured (L, R) measured (v, omega) true omega (+0.30, +0.0) (+0.3000, +0.3000) 1.0000 (+0.750, +0.750) (+0.2996, +0.2996) (+0.2996, +0.0000) +0.0000 (+0.20, +0.8) (+0.1400, +0.2600) 1.0000 (+0.350, +0.650) (+0.1395, +0.2593) (+0.1994, +0.7985) +0.8000 (+0.00, +2.0) (-0.1500, +0.1500) 1.0000 (-0.375, +0.375) (-0.1502, +0.1502) (+0.0000, +2.0023) +2.0000 (-0.20, +1.0) (-0.2750, -0.1250) 1.0000 (-0.688, -0.312) (-0.2756, -0.1258) (-0.2007, +0.9988) +1.0000 (+0.30, +2.0) (+0.1333, +0.4000) 0.8889 (+0.333, +1.000) (+0.1329, +0.3993) (+0.2661, +1.7759) +1.7778 (+0.40, +3.0) (+0.1120, +0.4000) 0.6400 (+0.280, +1.000) (+0.1123, +0.3993) (+0.2558, +1.9138) +1.9200 the last pass of the loop: period 0.020 s, measured mean dt 0.02000 s, 0 overruns

Read it from left to right. The wanted wheel speeds are the kinematics of section 4; the duty is the wheel speed divided by 0.40 (the believed curve has no dead zone); the measured wheel speeds come from the encoders, and back through the forward kinematics they give (v, omega), which matches the command to about a tenth of a percent in the first four rows, and the true turn rate, which the simulator knows, matches the command too. The last two rows ask for more than the wheels can do: for (0.30, 2.0) the faster wheel would need 0.45 m/s against a limit of 0.4, so both wheels scale by 0.8889 and the robot goes at 0.2661 m/s and turns at 1.7759 rad/s, the same 0.15 m circle (0.30 / 2.0 = 0.15; 0.2661 / 1.7759 = 0.1499). For (0.40, 3.0), the scale is 0.64. The last line is the loop statistics from Programming for Robotics 1 section 2: a period of 20 ms and 0 overruns, because simulated time does not run late, which is also why the simulator tells nothing about timing on the real board.

7. Moves: open loop first, then closed loop

A move is a motion written as a step function, the style of Programming for Robotics 1 section 5: update(base, dt) is called once per pass, returns (v, omega) for this pass, never waits, and sets done on the pass that finishes it. Writing a move this way, instead of as a function that loops until it arrives, keeps the main loop in charge: the loop still checks the guards, feeds the watchdog and logs every pass while the move runs, and a move can be started, stopped or replaced between any two passes. A Sequence of moves is itself a move.

The trapezoid profile and an open-loop move

Driving a distance D at a top speed vmax with an acceleration a: speed up at a for tacc = vmax / a, which covers dacc = vmax2 / (2a); cruise for (D − 2 dacc) / vmax; brake at a. If D is less than 2 dacc, the top speed is never reached and the profile is a triangle with the peak √(D a). An open-loop move follows that profile by the clock alone, with no encoders: it assumes the robot does what it is told. That is the simplest move and the baseline to measure the closed-loop one against.

class Move: """A motion written as a step function: update() is called once per pass and returns (v_mps, omega_rps). It never waits. done turns True on the pass that finishes it; failed says why it gave up (or None).""" done = False failed = None def update(self, base, dt_s): raise NotImplementedError class Trapezoid: """A speed profile: accelerate at `accel`, cruise at v_max, brake at `accel`, covering exactly `total` (positive). When total is too short to reach v_max it becomes a triangle with a lower peak.""" def __init__(self, total, v_max, accel): self.total = total self.v_max = min(v_max, math.sqrt(total * accel)) # the triangle's peak, if the trapezoid does not fit self.accel = accel self.t_acc = self.v_max / accel d_acc = 0.5 * accel * self.t_acc ** 2 self.t_cruise = (total - 2 * d_acc) / self.v_max self.t_total = 2 * self.t_acc + self.t_cruise def speed(self, t_s): if t_s <= 0.0 or t_s >= self.t_total: return 0.0 if t_s < self.t_acc: return self.accel * t_s if t_s < self.t_acc + self.t_cruise: return self.v_max return self.accel * (self.t_total - t_s) class OpenLoopMove(Move): """Follows a Trapezoid by the clock alone: no encoders, no odometry. It is only as good as the speed curve behind it. turn=False drives `total` metres (negative: backwards); turn=True turns `total` radians on the spot.""" def __init__(self, total, v_max, accel, turn=False): self.sign = 1.0 if total >= 0 else -1.0 self.profile = Trapezoid(abs(total), v_max, accel) self.turn = turn self.t_s = 0.0 def update(self, base, dt_s): self.t_s += dt_s if self.t_s >= self.profile.t_total: self.done = True return 0.0, 0.0 speed = self.sign * self.profile.speed(self.t_s) return (0.0, speed) if self.turn else (speed, 0.0)
p = Trapezoid(1.0, 0.3, 0.6) print("1.0 m at up to 0.3 m/s, acceleration 0.6 m/s^2: t_acc = %.3f s (%.3f m), cruise %.3f s, total %.3f s" % (p.t_acc, 0.5 * p.accel * p.t_acc ** 2, p.t_cruise, p.t_total)) print(" speed at t = 0, 0.25, 0.5, 1.5, 3.4, 3.7, 3.833 s: %s" % [round(p.speed(t), 3) for t in (0.0, 0.25, 0.5, 1.5, 3.4, 3.7, p.t_total)]) q = Trapezoid(0.1, 0.3, 0.6) print("0.1 m is too short to reach 0.3 m/s: a triangle with peak sqrt(0.1 x 0.6) = %.4f m/s, t_acc = %.3f s, total %.3f s" % (q.v_max, q.t_acc, q.t_total))
1.0 m at up to 0.3 m/s, acceleration 0.6 m/s^2: t_acc = 0.500 s (0.075 m), cruise 2.833 s, total 3.833 s speed at t = 0, 0.25, 0.5, 1.5, 3.4, 3.7, 3.833 s: [0.0, 0.15, 0.3, 0.3, 0.26, 0.08, 0.0] 0.1 m is too short to reach 0.3 m/s: a triangle with peak sqrt(0.1 x 0.6) = 0.2449 m/s, t_acc = 0.408 s, total 0.816 s

For 1.0 m at up to 0.3 m/s with an acceleration of 0.6 m/s2: the ramp takes 0.5 s and covers 0.075 m, so the cruise is (1.0 − 0.15) / 0.3 = 2.833 s and the move takes 3.833 s. For 0.1 m the top speed would take 0.075 m twice, which does not fit, and the profile is a triangle with a peak of 0.2449 m/s.

The closed-loop move, and how to stop on a target

A closed-loop move measures its progress with the odometry and stops when the measurement says it has arrived. The hard part is the end. If the controller commands full speed until the distance to go is zero, the robot overshoots: the motors take about a time constant to react, and the robot carries momentum. The closed-loop moves therefore ask each pass for the speed from which the robot could still stop in the distance left. With constant deceleration a, the speed v from which one can stop in a distance r satisfies v2 = 2 a r, so v = √(2 a r): the speed target falls as the square root of the remaining distance. Two refinements, both found by trying the plain version first:

  • The lead. The motors lag, so the robot will still travel about v × lead after the command has gone (the stopping distance of section 2: lead = the motors’ time constant, plus the delays of the loop and the speed filter). The move takes that off the distance left: r = remaining − speed × lead_s. With lead_s = 0 the robot overshoots; the demonstration below sweeps it.
  • The creep. Near the goal, √(2 a r) falls toward zero, and a motor with a dead zone does not turn for a tiny command. While the robot is still short of the target, the move never commands less than a small creep speed, so it arrives instead of stalling a few millimetres early. It finishes when it is within the tolerance and measured speed is below a settle threshold, and a time limit can turn a move that never arrives into failed = "timeout", a state the caller can see, not a robot that tries for ever.

DriveDistance adds an optional heading hold: omega = kp × (heading at start − heading now), a proportional controller on the angle (the P of the PID of Robotics Fundamentals 7, kept this simple on purpose until Programming for Robotics 6, planned and not yet written). TurnAngle does the same for an angle, using the total unwrapped turn of section 5, so that “turn 720 degrees” works. square() is four of each, in a Sequence.

class _Approach(Move): """The closed-loop part shared by DriveDistance and TurnAngle. Each pass it measures the distance still to go, asks for the speed from which it could still stop in time, sqrt(2 a r), and never more than v_max or `accel` faster than a pass before. lead_s is the lag of the motors: the distance they will still cover after the command is gone is taken off r.""" def __init__(self, total, v_max, accel, lead_s, tol, settle, creep, limit_s): self.total = total self.sign = 1.0 if total >= 0 else -1.0 self.v_max = v_max self.accel = accel self.lead_s = lead_s self.tol = tol self.settle = settle # speed below which the robot counts as stopped self.creep = creep # the slowest speed worth commanding while still short of the goal (a motor needs a push) self.limit_s = limit_s # give up after this long (None: never) self.start = None self.t_s = 0.0 self.speed = 0.0 # the commanded speed, always >= 0 (the sign is applied on the way out) self.error = None # what was still to go when it finished: the final error def update(self, base, dt_s): if self.start is None: self.start = self._progress(base) self._begin(base) self.t_s += dt_s remaining = self.sign * (self.total - (self._progress(base) - self.start)) r = remaining - self.speed * self.lead_s target = min(self.v_max, math.sqrt(2.0 * self.accel * max(r, 0.0))) if remaining > self.tol: target = max(target, self.creep) # not yet there: never ask for less than a creep self.speed = min(target, self.speed + self.accel * dt_s) # speeding up is limited, slowing down follows the profile if remaining <= self.tol and abs(self._measured(base)) < self.settle: self.done = True self.error = remaining return 0.0, 0.0 if self.limit_s is not None and self.t_s > self.limit_s: self.done, self.failed, self.error = True, "timeout", remaining return 0.0, 0.0 return self._output(base) def _begin(self, base): pass class DriveDistance(_Approach): """Drive `distance_m` straight (negative: backwards). Stops by odometry. kp_heading > 0 holds the starting heading.""" def __init__(self, distance_m, v_max=0.30, accel=0.6, kp_heading=0.0, lead_s=0.20, tol_m=0.003, settle_mps=0.02, creep_mps=0.015, limit_s=None): _Approach.__init__(self, distance_m, v_max, accel, lead_s, tol_m, settle_mps, creep_mps, limit_s) self.kp_heading = kp_heading # rad/s of turn per rad of heading error def _progress(self, base): return base.odom.distance_m def _measured(self, base): return base.v_mps def _begin(self, base): self.theta0 = base.odom.theta # the heading to hold def _output(self, base): omega = self.kp_heading * wrap_pi(self.theta0 - base.odom.theta) return self.sign * self.speed, omega class TurnAngle(_Approach): """Turn `angle_rad` on the spot (counter-clockwise positive; more than a full turn is fine). Stops by odometry.""" def __init__(self, angle_rad, w_max=1.5, alpha=3.0, lead_s=0.20, tol_rad=0.01, settle_rps=0.05, creep_rps=0.15, limit_s=None): _Approach.__init__(self, angle_rad, w_max, alpha, lead_s, tol_rad, settle_rps, creep_rps, limit_s) def _progress(self, base): return base.odom.turned_rad def _measured(self, base): return base.omega_rps def _output(self, base): return 0.0, self.sign * self.speed class Sequence(Move): """Moves one after the other. A move's last pass returns (0, 0); the next move starts on the following pass.""" def __init__(self, *moves): self.moves = list(moves) self.i = 0 def update(self, base, dt_s): if self.i < len(self.moves) and self.moves[self.i].done: self.i += 1 if self.i >= len(self.moves): self.done = True return 0.0, 0.0 out = self.moves[self.i].update(base, dt_s) if self.moves[self.i].failed is not None: self.done, self.failed = True, self.moves[self.i].failed # one failed move ends the sequence return out def square(side_m, ccw=True, kp_heading=0.0): """Four sides and four corners: the test drive of section 8 and the calibration run of section 10.""" corner = math.pi / 2 if ccw else -math.pi / 2 moves = [] for _ in range(4): moves.append(DriveDistance(side_m, kp_heading=kp_heading)) moves.append(TurnAngle(corner)) return Sequence(*moves)

A helper runs a move to its end and then keeps the loop going for a little longer, so the robot comes to rest before it is measured. It uses the same FixedRateLoop as the real program, with the simulator’s advance as its “sleep”, so the simulated world moves exactly when a real robot would have waited. The experiment drives 1 m six ways: an open-loop move and a closed-loop move on the plain simulator, and on the rig of section 3 (a robot whose motors are not what the software believes) an open-loop move with the believed curve, an open-loop move with the measured curve, and the closed-loop move with each:

def run_move(sim, base, move, max_s=120.0, after_s=0.8, log=None, trace=None): """Run a Move to its end, then keep the loop going for after_s so that the robot comes to rest. The loop of Programming for Robotics 1 section 4 drives the base and, through its sleep function, lets the simulated world move.""" base.start(move) loop = FixedRateLoop(0.02, sim.now_s, sim.advance) def step(dt_s): base.update(dt_s) if log is not None: log.add(sim.pose(), base.pose) if trace is not None: trace(sim, base, move) passes = 0 while base.move is not None and passes < int(max_s / 0.02): loop.run(step, 1) passes += 1 loop.run(step, int(round(after_s / 0.02))) return passes * 0.02 def one_drive(plant, make_move, curves=None): """A fresh robot (a simulator, or the rig of section 3 around one) runs one move. Returns (true distance driven, seconds, move.failed).""" sim = Simulator(World(8.0, 4.0), params=RobotParams(max_speed_mps=0.46), seed=1, pose=(1.0, 2.0, 0.0)) if plant == "plain": sim = Simulator(World(8.0, 4.0), seed=1, pose=(1.0, 2.0, 0.0)) hw = sim if plant == "plain" else RealMotors(sim, deadband=(0.12, 0.12)) base = DriveBase(hw, geom, curves=curves) move = make_move() seconds = run_move(sim, base, move) return sim.pose()[0] - 1.0, seconds, move.failed measured = (left_curve, right_curve) print("drive 1.000 m; error = true distance minus 1.000 m") print(" robot move error (mm) time (s) result") for plant, label, make_move, curves in ( ("plain", "open loop, believed curve", lambda: OpenLoopMove(1.0, 0.3, 0.6), None), ("plain", "closed loop, DriveDistance", lambda: DriveDistance(1.0), None), ("motors", "open loop, believed curve", lambda: OpenLoopMove(1.0, 0.3, 0.6), None), ("motors", "open loop, measured curve", lambda: OpenLoopMove(1.0, 0.3, 0.6), measured), ("motors", "closed loop, believed curve", lambda: DriveDistance(1.0, limit_s=8.0), None), ("motors", "closed loop, measured curve", lambda: DriveDistance(1.0, limit_s=8.0), measured)): distance, seconds, failed = one_drive(plant, make_move, curves) who = "as believed (plain simulator)" if plant == "plain" else "other motors (rig of section 3)" print(" %-31s %-38s %+8.1f %5.2f %s" % (who, label, (distance - 1.0) * 1000, seconds, failed or "ok"))
drive 1.000 m; error = true distance minus 1.000 m robot move error (mm) time (s) result as believed (plain simulator) open loop, believed curve -0.2 3.84 ok as believed (plain simulator) closed loop, DriveDistance +0.0 4.18 ok other motors (rig of section 3) open loop, believed curve +71.4 3.84 ok other motors (rig of section 3) open loop, measured curve -0.3 3.84 ok other motors (rig of section 3) closed loop, believed curve -9.3 8.02 timeout other motors (rig of section 3) closed loop, measured curve -0.1 4.18 ok

The first two rows are the sanity check: on the plain simulator, both moves are within 0.3 mm. The next two are the lesson about open loops: on the motors of section 3, the open-loop move with the believed speed curve overshoots by 71.4 mm because the duty-to-speed map is wrong (it commands a duty that gives more speed than believed, for the 3.8 s the clock says); with the measured curve the same open-loop move is 0.3 mm short, which is as good as the closed loop and shows what a calibrated map is worth. The last two rows are the surprise, and the reason they are in the experiment. Closed loop with the believed curve does not overshoot: it stops 9.3 mm short and times out. The believed map has no dead zone, so near the goal, where the move asks for a creep speed of 0.015 m/s, the duty that results is below the motor’s real dead zone and the wheels do not turn. The loop is working perfectly and has nothing to push with; the creep floor is expressed in speed, and a speed can only be converted to a duty by a map that knows the dead zone. With the measured curve the closed loop arrives at −0.1 mm. The two lessons: a closed loop does not remove the need for the speed curve, and a move with a timeout is far better than one without, because the timeout turned an invisible stall into a visible “timeout”.

The lead, measured

The remaining parameter is lead_s. It stands for the distance the robot still rolls when the command goes (v × lead), and the best value should be about the motor’s time constant plus the speed filter’s delay: here 0.08 s plus a few passes. Instead of guessing, sweep it, and look at the worst final error over a set of distances and angles (5 distances, positive and negative, and 5 turns), and at what it costs in time:

def worst_errors(lead_s): """Worst final error over several distances (mm) and several turns (degrees), and the time for 1 m, on the plain simulator.""" drive, spin = 0.0, 0.0 for d in (0.1, 0.5, 1.0, 2.0, -1.0): sim = Simulator(World(8.0, 4.0), seed=1, pose=(3.0, 2.0, 0.0)) base = DriveBase(sim, geom) seconds = run_move(sim, base, DriveDistance(d, lead_s=lead_s)) drive = max(drive, abs(sim.pose()[0] - 3.0 - d) * 1000) if d == 1.0: t1 = seconds for a in (15, 45, 90, -90, 180): sim = Simulator(World(8.0, 4.0), seed=1, pose=(3.0, 2.0, 0.0)) base = DriveBase(sim, geom) run_move(sim, base, TurnAngle(math.radians(a), lead_s=lead_s)) spin = max(spin, abs(math.degrees(wrap_pi(sim.pose()[2] - math.radians(a))))) return drive, spin, t1 print("lead_s worst drive error (mm) worst turn error (deg) time for 1 m (s)") for lead in (0.0, 0.08, 0.12, 0.20, 0.30): drive, spin, t1 = worst_errors(lead) print(" %.2f %6.2f %6.2f %.2f" % (lead, drive, spin, t1)) print() print("one run in detail, DriveDistance(1.0) with lead_s = 0.20 (v_max 0.3 m/s, accel 0.6 m/s^2):") rows = [] def watch(sim, base, move): if move.start is not None: remaining = 1.0 - (base.odom.distance_m - move.start) rows.append((base.t_s, remaining, move.speed, base.v_mps)) sim = Simulator(World(8.0, 4.0), seed=1, pose=(1.0, 2.0, 0.0)) base = DriveBase(sim, geom) move = DriveDistance(1.0) run_move(sim, base, move, trace=watch) first_slow = next(r for r in rows if r[0] > 1.0 and r[2] < 0.2999) print(" t (s) remaining (m) commanded v (m/s) measured v (m/s)") for t_mark in (0.5, 1.0, 2.0, 3.0, 3.2, 3.4, 3.6, 3.8, 4.0, 4.2): r = min(rows, key=lambda row: abs(row[0] - t_mark)) print(" %.2f %.4f %.4f %.4f" % r) print("braking starts (the command first falls below 0.3) at t = %.2f s with %.3f m to go; predicted v^2/(2a) + v x lead = %.3f + %.3f = %.3f m" % (first_slow[0], first_slow[1], 0.3 ** 2 / (2 * 0.6), 0.3 * 0.2, 0.3 ** 2 / (2 * 0.6) + 0.3 * 0.2)) print("final: odometry %.4f m, true %.4f m, error left over %.1f mm, move.error %.1f mm" % (base.odom.distance_m, sim.pose()[0] - 1.0, (sim.pose()[0] - 2.0) * 1000, move.error * 1000))
lead_s worst drive error (mm) worst turn error (deg) time for 1 m (s) 0.00 10.73 2.99 3.98 0.08 4.24 1.13 4.02 0.12 2.47 0.76 4.06 0.20 0.46 0.36 4.18 0.30 1.74 0.30 4.40 one run in detail, DriveDistance(1.0) with lead_s = 0.20 (v_max 0.3 m/s, accel 0.6 m/s^2): t (s) remaining (m) commanded v (m/s) measured v (m/s) 0.50 0.9479 0.3000 0.2035 1.00 0.8023 0.3000 0.2995 2.00 0.5021 0.3000 0.2996 3.00 0.2022 0.3000 0.3004 3.20 0.1420 0.3000 0.3014 3.40 0.0852 0.2184 0.2783 3.60 0.0426 0.1329 0.1997 3.80 0.0168 0.0592 0.1176 4.00 0.0048 0.0270 0.0502 4.20 0.0008 0.0120 0.0159 braking starts (the command first falls below 0.3) at t = 3.24 s with 0.130 m to go; predicted v^2/(2a) + v x lead = 0.075 + 0.060 = 0.135 m final: odometry 0.9999 m, true 1.0000 m, error left over 0.0 mm, move.error 1.1 mm

With no lead the worst drive error is 10.7 mm and the worst turn 3.0 degrees. The error falls to a minimum of 0.46 mm and 0.36 degrees at lead_s = 0.20 and rises again at 0.30 (1.74 mm), which is the other side of the trade: the robot brakes too early and finishes on the creep speed. Both are inside the 3 mm tolerance; the lead tunes where inside it the robot stops. The price of the lead is time: 3.98 s for 1 m with none, 4.18 s with 0.20 s. The detail run shows the braking begin when the distance to go is 0.130 m against a prediction of v2 / (2a) + v × lead = 0.075 + 0.060 = 0.135 m: the formula says where, to within 5 mm. The left-over error of the single run is below the 3 mm tolerance, and the move.error of 1.1 mm is odometry’s own view of it. The lead is a parameter to measure on the real robot, at its own speeds (section 13): this 0.20 s is a property of the simulated motor, 0.08 s lag plus the filter, not a constant of nature.

8. The square test, and a picture of the path

The standard test of a drive is a square: drive 1 m, turn 90 degrees, four times, and measure how far the robot ends from where it started. Odometry cannot be used to judge this test, since an odometry that is wrong about the robot will be wrong about its own end point too; the judge has to be outside, a tape measure on a real robot and the simulator’s true pose here. The helper start_frame_error expresses the end point in the start’s own frame (x ahead of where the robot began, y to its left), which is also how you would measure it on the floor; PathLog keeps the true path and the odometry’s together, and path_overlay draws both from above on a character grid, because a few numbers hide the shape of a failure that a picture shows at once.

class PathLog: """The true path and the odometry's, one point per pass, both in the world frame (the odometry starts at the robot's start pose).""" def __init__(self, start_pose): self.start = start_pose self.truth = [] self.odom = [] def add(self, truth_pose, odom_pose): self.truth.append((truth_pose[0], truth_pose[1])) x, y, _ = odom_pose c, s = math.cos(self.start[2]), math.sin(self.start[2]) self.odom.append((self.start[0] + c * x - s * y, self.start[1] + s * x + c * y)) def path_overlay(truth, odom, cols=60, margin_m=0.05): """Two paths from above on a character grid (x to the right, y up): * the true path, o the odometry's, @ both in one cell, S the start, E the true end, e the odometry's end.""" pts = truth + odom x0, x1 = min(p[0] for p in pts) - margin_m, max(p[0] for p in pts) + margin_m y0, y1 = min(p[1] for p in pts) - margin_m, max(p[1] for p in pts) + margin_m cell_w = (x1 - x0) / (cols - 1) cell_h = 2.0 * cell_w # a character is about twice as tall as it is wide rows = int((y1 - y0) / cell_h) + 1 grid = [[" "] * cols for _ in range(rows)] def put(p, ch, over=False): c = int((p[0] - x0) / cell_w) r = rows - 1 - int((p[1] - y0) / cell_h) old = grid[r][c] if over or old == " ": grid[r][c] = ch elif old in "*o" and ch in "*o" and old != ch: grid[r][c] = "@" # both paths in one cell for p in truth: put(p, "*") for p in odom: put(p, "o") put(truth[0], "S", True) put(odom[-1], "e", True) put(truth[-1], "E", True) return "\n".join("".join(row).rstrip() for row in grid) def start_frame_error(start_pose, end_pose): """The end point's offset from the start point in the START's own frame: x ahead of where the robot began, y to its left. On a real robot this is what a tape measure gives you; in the simulator it comes from the true pose.""" dx, dy = end_pose[0] - start_pose[0], end_pose[1] - start_pose[1] c, s = math.cos(start_pose[2]), math.sin(start_pose[2]) return c * dx + s * dy, -s * dx + c * dy

First a square on the plain simulator, where the robot is exactly what the software believes. Then the case that matters, a robot that is not: a right tyre 1 % bigger than the software thinks and a true track of 0.155 m instead of 0.150 m, driven for a 2 m square with the heading hold off.

ARENA = World(8.0, 8.0) START = (3.0, 4.0, 0.0) def go_square(side_m, ccw, params=None, geometry=None, kp_heading=4.0): """One square on a fresh robot. Returns (end error in the start frame (x, y) in m, heading change in rad, seconds, sim, base, log).""" sim = Simulator(ARENA, params=params, seed=1, pose=START) base = DriveBase(sim, geometry or geom) log = PathLog(START) seconds = run_move(sim, base, square(side_m, ccw, kp_heading), log=log) return start_frame_error(START, sim.pose()), wrap_pi(sim.pose()[2] - START[2]), seconds, sim, base, log print("a 1 m square on the plain simulator (the robot is exactly what the software believes); true end point against the start:") print(" direction x (mm) y (mm) distance (mm) heading (deg) odometry's own end pose (x, y, theta) time (s)") for ccw in (True, False): e, dh, seconds, sim, base, log = go_square(1.0, ccw) print(" %-9s %+6.1f %+6.1f %8.1f %+6.2f (%+.4f, %+.4f, %+.4f) %.1f" % ("ccw" if ccw else "cw", e[0] * 1000, e[1] * 1000, math.hypot(*e) * 1000, math.degrees(dh), *base.pose, seconds)) print() print("a 2 m square, counter-clockwise, on a robot that is NOT what the software believes (a right tyre 1 % bigger, a true track of 0.155 m):") wrong = RobotParams(track_width_m=0.155, scale_right=1.01) e, dh, seconds, sim, base, log = go_square(2.0, True, wrong) print(" true end point: x %+.3f m, y %+.3f m, heading %+.1f deg; odometry's end pose: (%+.4f, %+.4f, %+.4f)" % (e[0], e[1], math.degrees(dh), *base.pose)) print(" o = what odometry says it drove, * = where the robot really went, @ both, S start, E true end, e odometry's end") print(path_overlay(log.truth, log.odom, cols=64))
a 1 m square on the plain simulator (the robot is exactly what the software believes); true end point against the start: direction x (mm) y (mm) distance (mm) heading (deg) odometry's own end pose (x, y, theta) time (s) ccw +5.5 -5.4 7.7 +0.58 (+0.0054, -0.0055, +0.0108) 24.6 cw +5.5 +5.4 7.7 -0.58 (+0.0054, +0.0055, -0.0108) 24.6 a 2 m square, counter-clockwise, on a robot that is NOT what the software believes (a right tyre 1 % bigger, a true track of 0.155 m): true end point: x +0.416 m, y -0.274 m, heading +21.1 deg; odometry's end pose: (+0.0250, -0.0248, +0.0244) o = what odometry says it drove, * = where the robot really went, @ both, S start, E true end, e odometry's end ******* ********** * ooo oooooooooooooooooooooo@@@@@@@@@@ooooooooooooooo@oooo o o ******** * o o ******** ** o o******* * o ******@o ** o * o * o ** o * o ** o ** o * o * o ** o * o * o ** o ** o * o ** o * o * o ** o **o * o *@ * o @ * o @* **o o** *o o ** *o o * *o o ** *@ o ** @ o ** @ o * *@ o ** ********************o S@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@oooooooooooooooooooo e ** ** * *E

On the plain robot a 1 m square ends 7.7 mm from the start with a heading error of 0.58 degrees, in both directions; that is what the tolerances (3 mm and 0.01 rad per move) and the settling add up to over eight moves, and it is the best this method will do. On the robot that is not what the software believes, the end is 0.416 m ahead and 0.274 m to the right, with the robot facing 21.1 degrees off: a 2 m square misses by half a metre. The picture shows how. Odometry (the o path) believes it drew a perfect square and its end pose is (0.025, −0.025, 0.024), almost the start: the robot has no idea it is lost. The true path (*) is a spiral that opens, each side curving a little, each corner turning a little short. Nothing in the robot’s own data shows this, which is why the calibration of section 10 needs a measurement from outside.

9. Drift: where the errors come from, in code

Robotics Fundamentals 4 section 6 derived what a small error in the geometry does to a straight line and to a turn. Programming for Robotics 3 section 11 measured that on the simulator. This section repeats both with the real driving code, and adds what the drive code itself can and cannot fix. The first table checks the two worked examples of Robotics Fundamentals 4 section 6 against the code; the second shows what slip does; the third separates two kinds of fault that look alike at the end of a line and need different cures.

# 1. Robotics Fundamentals 4 section 6, worked example 1: a right tyre 1 % too big, 3 m of "straight" driving BIG = World(10.0, 4.0) sim = Simulator(BIG, params=RobotParams(scale_right=1.01), seed=1, pose=(1.0, 2.0, 0.0)) base = DriveBase(sim, geom) move = DriveDistance(3.0, kp_heading=4.0) # even with a heading hold: odometry cannot see this error run_move(sim, base, move) true_x, true_y, true_theta = sim.pose()[0] - 1.0, sim.pose()[1] - 2.0, sim.pose()[2] d_left = base.odom.distance_m # what the left wheel really rolled (its scale is 1.00) d_right = 1.01 * d_left ds_, dth_ = 0.5 * (d_left + d_right), (d_right - d_left) / 0.15 radius = ds_ / dth_ print("odometry says: x = %.4f m, y = %+.4f m, heading = %+.2f deg" % (base.pose[0], base.pose[1], math.degrees(base.pose[2]))) print("simulator truth: x = %.4f m, y = %+.4f m, heading = %+.2f deg" % (true_x, true_y, math.degrees(true_theta))) print("formula (RF4 6): x = %.4f m, y = %+.4f m, heading = %+.2f deg (R = ds / dtheta = %.3f m; for exactly 3.000 m: 2.995, 0.300, 11.46)" % (radius * math.sin(dth_), radius * (1 - math.cos(dth_)), math.degrees(dth_), radius)) print("rule of thumb eps D^2 / 2 with eps = 0.01 / 0.15 = %.4f rad/m: sideways %.4f m" % (0.01 / 0.15, 0.01 / 0.15 * d_left ** 2 / 2)) # 2. worked example 2: the true track is 0.155 m, the software believes 0.150 m; turn "360 degrees" sim = Simulator(BIG, params=RobotParams(track_width_m=0.155), seed=1, pose=(1.0, 2.0, 0.0)) base = DriveBase(sim, geom) spin = {"last": sim.pose()[2], "total": 0.0} def watch_spin(sim, base, move): spin["total"] += wrap_pi(sim.pose()[2] - spin["last"]) spin["last"] = sim.pose()[2] run_move(sim, base, TurnAngle(2 * math.pi), trace=watch_spin) print("spin: odometry says %.1f deg, the robot really turned %.1f deg (RF4: 348.4); ratio %.4f (0.150 / 0.155 = %.4f)" % (math.degrees(base.odom.turned_rad), math.degrees(spin["total"]), spin["total"] / base.odom.turned_rad, 0.150 / 0.155))
odometry says: x = 3.0001 m, y = +0.0000 m, heading = +0.00 deg simulator truth: x = 2.9951 m, y = +0.3005 m, heading = +11.46 deg formula (RF4 6): x = 2.9950 m, y = +0.3005 m, heading = +11.46 deg (R = ds / dtheta = 15.075 m; for exactly 3.000 m: 2.995, 0.300, 11.46) rule of thumb eps D^2 / 2 with eps = 0.01 / 0.15 = 0.0667 rad/m: sideways 0.3000 m spin: odometry says 360.2 deg, the robot really turned 348.6 deg (RF4: 348.4); ratio 0.9676 (0.150 / 0.155 = 0.9677)

The first run: the right tyre is 1 % bigger, so the right wheel rolls 1 % further than the encoders (counting turns of the wheel) think, and the robot arcs left. It is driven 3 m “straight” with the heading hold on, and odometry still says (3.0001, 0.0000, 0.00): from the wheel counts the robot did go straight, and that is what the hold controls. The simulator’s truth is (2.9951, 0.3005, 11.46 degrees), identical to the formula of Robotics Fundamentals 4 section 6 (2.995, 0.300, 11.46 degrees) and to the rule of thumb of D2 / 2 × (0.01 / 0.15) = 0.3000 m of sideways drift in 3 m. A heading hold built on odometry cannot repair an error that odometry does not see. The second: with a true track of 0.155 m and a believed track of 0.150 m, a “360 degree” spin by odometry is a real 348.6 degrees (Robotics Fundamentals 4 gave 348.4: the difference is where the controller stops); the ratio 0.9676 is 0.150 / 0.155.

The next question is what slip does, because a wheel that slips is a tyre whose effective size has shrunk, and so a wheel whose odometry over-counts. It was run with the heading hold on, at 2 m, which is the test 5 of Robotics Fundamentals 10 section 9.8:

print("2 m 'straight' with the heading hold on; the wheel scales are what the SIMULATOR does to the ground distance (1.01 = tyre 1 % bigger, 0.98 = 2 % slip):") print(" scale (left, right) odometry distance true x (m) true y (mm) heading (deg) what it is") for sl, sr, what in ((1.0, 1.0, "as believed"), (1.0, 1.01, "right tyre 1 % bigger"), (1.0, 0.98, "right wheel slips 2 %"), (0.98, 0.98, "both wheels slip 2 %")): sim = Simulator(BIG, params=RobotParams(scale_left=sl, scale_right=sr), seed=1, pose=(1.0, 2.0, 0.0)) base = DriveBase(sim, geom) run_move(sim, base, DriveDistance(2.0, kp_heading=4.0)) print(" (%.2f, %.2f) %.4f m %.4f %+7.1f %+6.2f %s" % (sl, sr, base.odom.distance_m, sim.pose()[0] - 1.0, (sim.pose()[1] - 2.0) * 1000, math.degrees(sim.pose()[2]), what))
2 m 'straight' with the heading hold on; the wheel scales are what the SIMULATOR does to the ground distance (1.01 = tyre 1 % bigger, 0.98 = 2 % slip): scale (left, right) odometry distance true x (m) true y (mm) heading (deg) what it is (1.00, 1.00) 2.0002 m 2.0002 +0.0 +0.00 as believed (1.00, 1.01) 2.0002 m 2.0043 +133.8 +7.64 right tyre 1 % bigger (1.00, 0.98) 2.0002 m 1.9568 -262.5 -15.28 right wheel slips 2 % (0.98, 0.98) 2.0002 m 1.9602 +0.0 +0.00 both wheels slip 2 %

A right tyre 1 % bigger gives 133.8 mm sideways and 7.64 degrees at 2 m, which is the “133 mm” of the test plan of Robotics Fundamentals 10 section 9.8. A right wheel that slips 2 % (scale 0.98) drifts the other way, 262.5 mm: the number is about twice as big as for 1 %, as expected (it scales with the size of the mismatch, here 2 % where the tyre was 1 %, and the sign flips). And if both wheels slip 2 %, the robot goes dead straight and ends 2 % short; no drift, no hint on the heading, and an odometry distance of 2.0002 m for a true x of 1.9602 m. A straight-line distance error is a scale error, and it needs a tape measure, not a heading measurement, to find it.

Two kinds of fault, and which fix works on which

A robot that curves has two possible causes that look the same on the floor. In one, the geometry is wrong (a bigger tyre, a wrong track): odometry itself is wrong, and neither a heading hold nor a better motor map can see it. In the other, the motors differ (different dead zones, a weaker side): odometry is right, it sees the robot curving, and the controller can correct it. The experiment drives a 2 m straight with each fault, with and without the hold, and with the believed or measured speed curves:

def drive_2m(hw, sim, curves, kp): base = DriveBase(hw, geom, curves=curves) run_move(sim, base, DriveDistance(2.0, kp_heading=kp)) return (sim.pose()[1] - 2.0) * 1000, math.degrees(sim.pose()[2]) def other_motors(): sim = Simulator(BIG, params=RobotParams(max_speed_mps=0.46), seed=1, pose=(1.0, 2.0, 0.0)) return RealMotors(sim, deadband=(0.10, 0.14), gain=(1.0, 0.96)), sim # different deadbands, a right motor 4 % weaker def bigger_tyre(): sim = Simulator(BIG, params=RobotParams(max_speed_mps=0.46, scale_right=1.01), seed=1, pose=(1.0, 2.0, 0.0)) return RealMotors(sim, deadband=(0.12, 0.12)), sim _, other_curves = measure_speed_curves(lambda: other_motors()[0], mpt, mpt, sweep) print("2 m 'straight': sideways error at the end and heading error, for two different faults") print(" fault speed curves heading hold sideways (mm) heading (deg)") for label, make, curves, cname in (("the motors differ", other_motors, None, "believed"), ("the motors differ", other_motors, other_curves, "measured"), ("right tyre 1 % bigger", bigger_tyre, measured, "measured")): for kp in (0.0, 4.0): hw, sim = make() side, heading = drive_2m(hw, sim, curves, kp) print(" %-22s %-8s %-5s %+8.1f %+7.2f" % (label, cname, "off" if kp == 0 else "on", side, heading))
2 m 'straight': sideways error at the end and heading error, for two different faults fault speed curves heading hold sideways (mm) heading (deg) the motors differ believed off -791.4 -52.75 the motors differ believed on -49.4 -1.53 the motors differ measured off +2.6 +0.16 the motors differ measured on +0.1 +0.01 right tyre 1 % bigger measured off +133.8 +7.64 right tyre 1 % bigger measured on +133.8 +7.64

Reading the table. With different motors and no measured curves and no heading hold, the robot ends 791.4 mm off line and 52.75 degrees round: the drive is unusable. A heading hold alone brings that to −49.4 mm and −1.53 degrees, because odometry sees the curving and the controller corrects it. Per-wheel measured curves alone bring it to +2.6 mm and 0.16 degrees, because the cause is removed rather than corrected after the fact, and both together give +0.1 mm. With the bigger tyre, neither helps: 133.8 mm and 7.64 degrees whether the hold is on or off, since the hold is working from an odometry that is wrong the same way as the tyre. This is the dividing line of the whole chapter: faults that odometry can see are corrected by control; faults that odometry cannot see have to be measured, from outside, and put into the geometry. That is the next section.

10. Calibration: three measurements, three corrections

Section 9 left a list of numbers the software gets wrong and cannot see: the metres per tick of each wheel and the track width. They are measured from outside the robot, with a tape measure, in three steps, each correcting one thing, each needing no equipment beyond a floor, a tape and two squares of masking tape. The robot used is the one of section 8: a right tyre 1 % bigger and a true track of 0.155 m, neither of which the software knows.

Step 1: the scale of the straight line

Drive a long straight line, measure it with the tape, and compare it with what odometry says. The ratio multiplies both wheels’ metres per tick. It finds the mean of the two wheels’ scale errors, and nothing else: here (1.00 + 1.01) / 2 = 1.005, and the chord of an arc is shorter than the arc, which is why the measurement is 1.0043 and not 1.005.

# the robot of section 8 that is not what the software believes: a right tyre 1 % bigger, a true track of 0.155 m wrong = RobotParams(track_width_m=0.155, scale_right=1.01) # step 1: a straight line, measured with a "tape" (the true start and end points). Both wheels' scale at once. sim = Simulator(BIG, params=wrong, seed=1, pose=(1.0, 2.0, 0.0)) base = DriveBase(sim, geom) run_move(sim, base, DriveDistance(2.0)) tape = math.hypot(sim.pose()[0] - 1.0, sim.pose()[1] - 2.0) scale = tape / base.odom.distance_m geom1 = geom.scaled(left=scale, right=scale) print("step 1, straight line: odometry says %.4f m, the tape says %.4f m -> scale both wheels by %.4f" % (base.odom.distance_m, tape, scale)) print(" (the true mean of the two wheels' scales is (1.00 + 1.01) / 2 = 1.005; the chord is a little shorter than the path, hence %.4f)" % scale) print(" now %s" % geom1)
step 1, straight line: odometry says 2.0002 m, the tape says 2.0088 m -> scale both wheels by 1.0043 (the true mean of the two wheels' scales is (1.00 + 1.01) / 2 = 1.005; the chord is a little shorter than the path, hence 1.0043) now Geometry(mpt_left=0.341809 mm, mpt_right=0.341809 mm, track=150.00 mm)

Step 2: two squares, and what their end points say

A difference between the wheels, and a wrong track, cannot be found from a straight line and a spin alone, since each is contaminated by the other (the last line of the final demonstration of this section shows it). The method of Borenstein and Feng, as I remember it (I did not check the original; their paper is the place for details), is to drive the same square twice, once clockwise and once counter-clockwise, and to use the two end-point errors, because the two directions respond differently to the two kinds of error. The derivation that follows is mine, checked below in the simulator; the formulas were not copied.

Describe the robot by two small numbers. phi is the heading that each straight side adds, in radians (a wheel mismatch: the right wheel rolling further turns the robot to the left). psi is how much each corner turns beyond 90 degrees (a track error: a corner commanded as 90 degrees really turns 90 degrees × believed track / true track, which is less than 90 if the believed track is too small). For a counter-clockwise square, side k (k = 0 to 3) is driven at a heading k (90° + phi + psi) + phi / 2, since each earlier side and corner has added phi + psi + 90 degrees, and the side itself curves by phi, so on average it points phi / 2 further round. To first order in the small angles, a change e in the direction of a side of length L moves the end point of the square by L e at right angles to that side. Adding up the four sides, the sides at 0 and 180 degrees move the end point in y only, and the two at 90 and 270 degrees move it in x by −L e1 and +L e3. With e1 = phi + psi + phi / 2 and e3 = 3 (phi + psi) + phi / 2, the x error is L (e3 − e1) = 2 L (phi + psi). For the clockwise square each corner turns the other way, and the same sum gives −2 L (phi − psi). So the sum and the difference of the two measured x errors separate the two causes:

x_ccw = 2 L (phi + psi) x_cw = -2 L (phi - psi) x_ccw - x_cw = 4 L phi phi = (x_ccw - x_cw) / (4 L) the wheel mismatch x_ccw + x_cw = 4 L psi psi = (x_ccw + x_cw) / (4 L) the track error

Then the corrections. A corner that turns 90 degrees + psi means (π / 2) × (believed track / true track) = π / 2 + psi, so the true track = believed track / (1 + 2 psi / π). The wheel mismatch: along one side of length L the right wheel rolls delta L further than the left, with phi = delta × L / track, so delta = phi × track / L; the two wheel scales are then 2 / (2 + delta) and 2 (1 + delta) / (2 + delta), whose ratio is 1 + delta and whose mean is 1, so that this step leaves the straight-line scale of step 1 alone. The first-order result is exact only for small angles, and the table below puts a number on that (it is the reason for the Newton steps in umbmark_fit): square_end_model is the exact end point from phi and psi, computed with the same pose_step as the robot.

def square_end_model(side_m, phi, psi, ccw): """Where a square of four sides ends, from two numbers: phi = the heading each straight side adds (rad, counter-clockwise positive: a wheel mismatch) and psi = how much each corner turns more than 90 degrees (rad: a track error). Returns (x, y) in the start frame.""" x, y, theta = 0.0, 0.0, 0.0 corner = (math.pi / 2 + psi) * (1.0 if ccw else -1.0) for _ in range(4): x, y, theta = pose_step(x, y, theta, side_m, phi) theta = wrap_pi(theta + corner) return x, y def umbmark_fit(side_m, x_cw, x_ccw): """Find (phi, psi) from the x end-point errors of a clockwise and a counter-clockwise square. Starts from the first-order answer psi = (x_ccw + x_cw) / 4L, phi = (x_ccw - x_cw) / 4L and refines it with Newton steps on square_end_model.""" phi = (x_ccw - x_cw) / (4.0 * side_m) psi = (x_ccw + x_cw) / (4.0 * side_m) for _ in range(20): f1 = square_end_model(side_m, phi, psi, True)[0] - x_ccw f2 = square_end_model(side_m, phi, psi, False)[0] - x_cw if abs(f1) + abs(f2) < 1e-10: break h = 1e-6 a1 = (square_end_model(side_m, phi + h, psi, True)[0] - x_ccw - f1) / h b1 = (square_end_model(side_m, phi, psi + h, True)[0] - x_ccw - f1) / h a2 = (square_end_model(side_m, phi + h, psi, False)[0] - x_cw - f2) / h b2 = (square_end_model(side_m, phi, psi + h, False)[0] - x_cw - f2) / h det = a1 * b2 - a2 * b1 phi -= (b2 * f1 - b1 * f2) / det psi -= (a1 * f2 - a2 * f1) / det return phi, psi def umbmark_geometry(geom, side_m, x_cw, x_ccw): """The corrected Geometry from one clockwise and one counter-clockwise square. Returns (geometry, phi, psi). The track is corrected from psi, then the wheel ratio from phi; the average wheel scale is left alone (the straight-line test sets it).""" phi, psi = umbmark_fit(side_m, x_cw, x_ccw) track = geom.track_m / (1.0 + 2.0 * psi / math.pi) # a corner turns (90 deg x believed track / true track) delta = phi * track / side_m # right wheel's ground distance / left's - 1, as odometry sees it now c_left = 2.0 / (2.0 + delta) c_right = 2.0 * (1.0 + delta) / (2.0 + delta) # c_right / c_left = 1 + delta, and their mean is 1 return geom.scaled(c_left, c_right, track / geom.track_m), phi, psi
print("first-order theory against the exact model, side 2 m: x_ccw = 2 L (phi + psi), x_cw = -2 L (phi - psi)") print(" phi (rad) psi (rad) x_ccw exact first order x_cw exact first order") for phi, psi in ((0.005, -0.005), (0.02, -0.01), (0.07, -0.05), (0.13, -0.05)): xe_ccw, xe_cw = square_end_model(2.0, phi, psi, True)[0], square_end_model(2.0, phi, psi, False)[0] print(" %+7.3f %+7.3f %+8.4f %+8.4f %+8.4f %+8.4f" % (phi, psi, xe_ccw, 2 * 2.0 * (phi + psi), xe_cw, -2 * 2.0 * (phi - psi))) phi, psi = 0.13, -0.05 x_cw, x_ccw = square_end_model(2.0, phi, psi, False)[0], square_end_model(2.0, phi, psi, True)[0] print("first-order answer from those two x values: phi = %.4f, psi = %.4f (truth 0.13, -0.05)" % ((x_ccw - x_cw) / 8.0, (x_ccw + x_cw) / 8.0)) print("after the Newton steps of umbmark_fit: phi = %.4f, psi = %.4f" % umbmark_fit(2.0, x_cw, x_ccw))
first-order theory against the exact model, side 2 m: x_ccw = 2 L (phi + psi), x_cw = -2 L (phi - psi) phi (rad) psi (rad) x_ccw exact first order x_cw exact first order +0.005 -0.005 +0.0000 +0.0000 -0.0395 -0.0400 +0.020 -0.010 +0.0408 +0.0400 -0.1149 -0.1200 +0.070 -0.050 +0.0842 +0.0800 -0.3869 -0.4800 +0.130 -0.050 +0.3575 +0.3200 -0.4784 -0.7200 first-order answer from those two x values: phi = 0.1045, psi = -0.0151 (truth 0.13, -0.05) after the Newton steps of umbmark_fit: phi = 0.1300, psi = -0.0500

For phi = 0.005 rad the first-order formula is within 1.3 % of the exact end point, for 0.02 rad within 5 %, but by phi = 0.13 rad it is out by 10 % on xccw and by 50 % on xcw, and the first-order answer would have given phi = 0.1045 and psi = −0.0151 against the truth 0.13 and −0.05. The Newton iteration on the exact model recovers 0.1300 and −0.0500. This is not an artificial case: phi is the heading gained per side, and a right tyre 1 % bigger than the left gives 0.01 × 2 / 0.15 = 0.133 rad over a 2 m side, the mismatch that the test plan of Robotics Fundamentals 10 section 9.8 treats as typical. So for a 2 m square the exact model is needed; a shorter side or a smaller mismatch brings the first-order formula back into range.

Step 3: do it, and check it

The two squares, with the heading hold off (kp = 0) so that the drift shows (a hold would hide the very thing being measured), then the fit, then the corrected geometry:

# step 2: a clockwise and a counter-clockwise square, the heading hold OFF (kp = 0) so that the drift shows SIDE = 2.0 e_cw = go_square(SIDE, False, wrong, geom1, kp_heading=0.0)[0] e_ccw = go_square(SIDE, True, wrong, geom1, kp_heading=0.0)[0] print("step 2, two squares of %.1f m with no heading hold, end point against the start (the 'tape' measurement), in mm:" % SIDE) print(" clockwise: x %+8.1f y %+8.1f" % (e_cw[0] * 1000, e_cw[1] * 1000)) print(" counter-clockwise: x %+8.1f y %+8.1f" % (e_ccw[0] * 1000, e_ccw[1] * 1000)) geom2, phi, psi = umbmark_geometry(geom1, SIDE, e_cw[0], e_ccw[0]) print("fit: phi = %.4f rad per side (the right wheel rolls further), psi = %.4f rad per corner (corners turn short)" % (phi, psi)) print(" the model's y values for those squares: ccw %+.1f mm, cw %+.1f mm (measured %+.1f, %+.1f): the structure fits" % (square_end_model(SIDE, phi, psi, True)[1] * 1000, square_end_model(SIDE, phi, psi, False)[1] * 1000, e_ccw[1] * 1000, e_cw[1] * 1000)) print("before: %s" % geom1) print("after: %s" % geom2) print("truth: wheel ratio right / left = 1.0100 (now %.4f), track 155.00 mm" % (geom2.mpt_right / geom2.mpt_left))
step 2, two squares of 2.0 m with no heading hold, end point against the start (the 'tape' measurement), in mm: clockwise: x -471.5 y -952.1 counter-clockwise: x +369.7 y -251.4 fit: phi = 0.1286 rad per side (the right wheel rolls further), psi = -0.0460 rad per corner (corners turn short) the model's y values for those squares: ccw -251.4 mm, cw -953.5 mm (measured -251.4, -952.1): the structure fits before: Geometry(mpt_left=0.341809 mm, mpt_right=0.341809 mm, track=150.00 mm) after: Geometry(mpt_left=0.340119 mm, mpt_right=0.343499 mm, track=154.52 mm) truth: wheel ratio right / left = 1.0100 (now 1.0099), track 155.00 mm

The squares end at (−471.5, −952.1) mm clockwise and (+369.7, −251.4) mm counter-clockwise; only the two x values go into the fit. It finds phi = 0.1286 rad per side and psi = −0.0460 rad per corner (the corners turn short, as they should with a believed track that is too small), and the model with those two numbers reproduces the y errors that were not used in the fit: −251.4 mm (measured −251.4) and −953.5 mm (measured −952.1). That is the check that the two-parameter structure really describes this robot. The corrected geometry has a right-to-left ratio of 1.0099 (truth 1.0100) and a track of 154.52 mm (truth 155.00 mm). I did not chase the remaining 0.48 mm; the check below shows that it is small enough.

Now the check that counts: the same squares, now with the heading hold on as in real driving, before and after the correction, and the straight line and the spin of Robotics Fundamentals 10 section 9.8, before and after:

# step 3: the same squares again, now with the heading hold ON (kp = 4), before and after the correction print("step 3, squares of %.1f m with the heading hold on (what you would really drive):" % SIDE) print(" geometry direction x (mm) y (mm) distance (mm) heading (deg)") for name, g in (("before", geom), ("after", geom2)): for ccw in (True, False): e, dh, seconds, sim, base, log = go_square(SIDE, ccw, wrong, g) print(" %-8s %-9s %+7.1f %+7.1f %8.1f %+6.2f" % (name, "ccw" if ccw else "cw", e[0] * 1000, e[1] * 1000, math.hypot(*e) * 1000, math.degrees(dh))) e, dh, seconds, sim, base, log = go_square(SIDE, True, wrong, geom2) print("the calibrated robot, counter-clockwise (compare the picture of section 8):") print(path_overlay(log.truth, log.odom, cols=64))
step 3, squares of 2.0 m with the heading hold on (what you would really drive): geometry direction x (mm) y (mm) distance (mm) heading (deg) before ccw +415.6 -274.1 497.9 +21.07 before cw -460.3 -902.6 1013.2 +38.08 after ccw -1.2 +0.6 1.3 +0.18 after cw -0.4 +4.6 4.7 -0.03 the calibrated robot, counter-clockwise (compare the picture of section 8): @@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@* @ o* @ o@ @ @ @ @ @ @ @ @ @ @ @ @ @ @ @ @ @ @ @ @ @ @ @ @ @ @ @ @ @ @ @o @ *o @ *o @ *o @ *o @ *o @ *o @ *o @ *o @ *o @ *o @ *o ***@@@@@@@@@@@@@@@@@@@@@ Ee@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@oo
def true_spin(params, geometry, angle_rad): """Turn on the spot by odometry and return (odometry's angle, the true angle) in degrees. The true angle adds up the real heading changes.""" sim = Simulator(BIG, params=params, seed=1, pose=(1.0, 2.0, 0.0)) base = DriveBase(sim, geometry) state = {"last": sim.pose()[2], "total": 0.0} def watch(sim, base, move): state["total"] += wrap_pi(sim.pose()[2] - state["last"]) state["last"] = sim.pose()[2] run_move(sim, base, TurnAngle(angle_rad), trace=watch) return math.degrees(base.odom.turned_rad), math.degrees(state["total"]) def straight_3m(params, geometry): sim = Simulator(BIG, params=params, seed=1, pose=(1.0, 2.0, 0.0)) base = DriveBase(sim, geometry) run_move(sim, base, DriveDistance(3.0, kp_heading=4.0)) return (sim.pose()[1] - 2.0) * 1000, math.degrees(sim.pose()[2]), base.odom.distance_m print("the tests 5 and 6 of Robotics Fundamentals 10 section 9.8 (its test 5 drives 2 m; 3 m here, the drift example of Robotics Fundamentals 4 section 6), before and after this calibration:") print(" straight 3 m, heading hold on: sideways (mm) heading (deg) odometry distance (m)") for name, g in (("before", geom), ("after ", geom2)): side, heading, dist = straight_3m(wrong, g) print(" %s %+8.1f %+6.2f %.4f" % (name, side, heading, dist)) print(" spin of 360 degrees: odometry (deg) true (deg)") for name, g in (("before", geom), ("after ", geom2)): odo_angle, true_angle = true_spin(wrong, g, 2 * math.pi) print(" %s %8.1f %8.1f" % (name, odo_angle, true_angle)) odo_angle, true_angle = true_spin(wrong, geom, 2 * math.pi) print("the spin test on its own would have given a track of %.4f x %.1f / %.1f = %.2f mm (the squares found %.2f mm; truth 155.00 mm)" % (geom.track_m, odo_angle, true_angle, geom.track_m * odo_angle / true_angle * 1000, geom2.track_m * 1000))
the tests 5 and 6 of Robotics Fundamentals 10 section 9.8 (its test 5 drives 2 m; 3 m here, the drift example of Robotics Fundamentals 4 section 6), before and after this calibration: straight 3 m, heading hold on: sideways (mm) heading (deg) odometry distance (m) before +290.9 +11.09 3.0001 after +16.0 +0.09 3.0000 spin of 360 degrees: odometry (deg) true (deg) before 360.2 350.3 after 360.3 359.4 the spin test on its own would have given a track of 0.1500 x 360.2 / 350.3 = 154.25 mm (the squares found 154.52 mm; truth 155.00 mm)

A 2 m square that ended 498 mm from the start (counter-clockwise) and 1013 mm (clockwise) now ends 1.3 mm and 4.7 mm from it, with a heading error of 0.18 and −0.03 degrees. The picture is the same square as in section 8, now closed. The 3 m straight line that drifted 290.9 mm sideways and ended 11.09 degrees round is now 16.0 mm and 0.09 degrees; the 360 degree spin that really turned 350.3 degrees now turns 359.4. The last line is the reason for two squares: the spin test alone, which is the method of Robotics Fundamentals 10 section 9.8, would have corrected the track to 154.25 mm, 0.75 mm short of the truth, because a spin on the spot is also moved by the difference between the wheels. In the simulator a single measurement can be a few hundredths of a millimetre off; on a real robot the tape and the floor give a few millimetres of spread, and the squares should be repeated and averaged. A tape error of 3 mm in each x value is 3 / 8000 = 0.0004 rad on phi and on psi for a 2 m square, which moves the wheel ratio by 0.003 % and the track by 0.04 mm; the square makes the measurement insensitive to the tape, and what limits real calibration is how repeatable the robot is on its floor.

11. Safe behaviour: cap, watchdog, guards and a stop that cannot fail

Robotics Fundamentals 10 section 7 sets rules for a robot that must not hurt itself or anyone; among them: speed-limit the first runs, treat the absence of a good signal as a fault, stop on silence. This section is those rules as code. The part of the class that matters most is not clever: each guard is a few lines, and its behaviour is a number you can measure.

The duty cap

The max_duty argument of DriveBase caps the duty, and with measured curves it also caps the speed that set_velocity can produce: max_wheel_mps is the speed of the slower wheel at the cap, and a command above it is scaled down along the same circle (section 4). It is a design choice that the cap is a property of the base, not of the controller: a control loop that has a bug, or wants 5 m/s, is still limited.

Watchdog, encoder guard and stall guard

  • Command watchdog (not latching): if no set_velocity has been called for the timeout, the motors are zeroed, until one is called again. It catches a controller that has crashed or hung, while the rest of the program (and the loop) still runs. The watchdog on the board, the WDT of section 13, catches the case where the loop itself stops.
  • Encoder guard (latching): the encoder reads fail (the Sensor / Health of Programming for Robotics 4 sections 2 and 10) or stop arriving. A counter that does not change is normal for a robot at rest, so “stuck” is switched off in this guard, and the next guard looks for “driven but not moving”.
  • Stall guard (latching): a wheel told to move at least 0.04 m/s that measures under 10 % of what it was told, for 0.3 s, is stalled (a jammed wheel, or a broken encoder wire). Robotics Fundamentals 10 section 7 states the rule in terms of duty (above 30 % duty and under 10 % of the expected speed); this version works from the wanted speed, which is what the base knows at that point.

The difference between hold and fault is deliberate. A watchdog timeout is the controller being late or absent: when it speaks again, the base resumes. An encoder or stall fault means the robot’s knowledge of itself is no longer trustworthy: it latches, the motors stay off, new commands are ignored, and only clear_fault(), an explicit act by the program or the person, lets it go; even then the base does not move until a new command arrives. The first cause wins and stays on record.

class CommandWatchdog: """Not latching: if no set_velocity (or Move) has spoken for timeout_s, the motors are zeroed until one speaks again.""" latching = False def __init__(self, base, timeout_s=0.25): self.timeout_s = timeout_s base.guards.append(self) # creating a guard switches it on def check(self, base, dt_s): if base.last_cmd_t is None or base.t_s - base.last_cmd_t > self.timeout_s: return "no command" return None class EncoderGuard: """Latching: the encoder reads fail (bad) or stop arriving (stale), as Health of Programming for Robotics 4 defines them. A counter that stays put is normal for a robot at rest, so 'stuck' is switched off here; StallGuard covers 'driven but not moving'.""" latching = True def __init__(self, base, bad_max=5, timeout_s=0.11): clock = lambda: base.t_s self.sensors = [Sensor(lambda i=i: base.raw_ticks[i], clock, -2 ** 40, 2 ** 40, timeout_s, name=name) for i, name in enumerate(("left", "right"))] self.health = [Health(s, stuck_n=10 ** 9, bad_max=bad_max) for s in self.sensors] base.guards.append(self) def check(self, base, dt_s): for sensor, health in zip(self.sensors, self.health): sensor.read() fault = health.update() if fault is not None: return "encoder %s %s" % (sensor.name, fault) return None class StallGuard: """Latching: a wheel that is told to move at least min_mps but measures under `fraction` of that, for time_s, is stalled (a jammed wheel, a broken encoder wire): the rule of Robotics Fundamentals 10 section 7, in code.""" latching = True def __init__(self, base, min_mps=0.04, fraction=0.1, time_s=0.3): self.min_mps = min_mps self.fraction = fraction self.time_s = time_s self.timers = [0.0, 0.0] base.guards.append(self) def check(self, base, dt_s): for i, name in enumerate(("left", "right")): wanted, measured = abs(base.wanted_mps[i]), abs(base.wheel_mps[i]) if wanted >= self.min_mps and measured < self.fraction * wanted: self.timers[i] += dt_s else: self.timers[i] = 0.0 if self.timers[i] >= self.time_s: return "stall %s" % name return None

The test rigs for faults come next: a wrapper that makes the encoders raise OSError from some time on (a dead bus), one that freezes one wheel’s tick count while the motors still run (a stalled wheel or a broken wire), and a small IdealHardware object with no simulator at all, used by the unit tests of section 12.

class DeadEncoders: """Test rig: from fail_after_s on, ticks() raises OSError, like a bus that has died.""" def __init__(self, hw, fail_after_s): self.hw = hw self.fail_after_s = fail_after_s def ticks(self): if self.hw.now_s() >= self.fail_after_s: raise OSError("encoder bus timeout") return self.hw.ticks() def __getattr__(self, name): return getattr(self.hw, name) class StuckWheel: """Test rig: from after_s on, one wheel's tick count freezes (index 0 = left), as with a jammed wheel or a broken wire.""" def __init__(self, hw, wheel, after_s): self.hw = hw self.wheel = wheel self.after_s = after_s self.frozen = None def ticks(self): ticks = list(self.hw.ticks()) if self.hw.now_s() >= self.after_s: if self.frozen is None: self.frozen = ticks[self.wheel] ticks[self.wheel] = self.frozen return tuple(ticks) def __getattr__(self, name): return getattr(self.hw, name) class IdealHardware: """Test rig that needs no simulator: wheels that follow the duty exactly (no lag, no noise), whole-count ticks, a clock. gain scales each wheel's speed (a weaker motor); the hardware interface is ticks(), set_duty(), now_s() and advance().""" def __init__(self, mpt_m, top_mps=0.40, gain=(1.0, 1.0)): self.mpt_m = mpt_m self.top_mps = top_mps self.gain = gain self.duty = (0.0, 0.0) self.commands = [] # every set_duty call, for the tests to read self.t_s = 0.0 self._ticks = [0.0, 0.0] def set_duty(self, left, right): self.duty = (clamp(left, -1.0, 1.0), clamp(right, -1.0, 1.0)) self.commands.append(self.duty) def ticks(self): return int(self._ticks[0]), int(self._ticks[1]) def now_s(self): return self.t_s def advance(self, dt_s): for i in (0, 1): self._ticks[i] += self.duty[i] * self.gain[i] * self.top_mps * dt_s / self.mpt_m self.t_s += dt_s

First the cap. Asking for 0.40 m/s from the plain simulator with the believed curve, and from the motor rig of section 3 with the measured curve, with the cap off and at 0.25:

print("the duty cap for first runs (Robotics Fundamentals 10 section 7): ask for 0.40 m/s and see what the robot does") print(" %-34s %-4s %-14s %-6s %-14s %s" % ("robot and curve", "cap", "max wheel", "scale", "measured", "duty")) for label, make, curves in (("believed curve, plain simulator", lambda: Simulator(BIG, seed=1, pose=(1.0, 2.0, 0.0)), None), ("measured curve, rig of section 3", lambda: make_motors(), measured)): for cap in (1.0, 0.25): hw = make() sim = hw if isinstance(hw, Simulator) else hw.hw base = DriveBase(hw, geom, curves=curves, max_duty=cap) hold_velocity(sim, base, 0.40, 0.0) print(" %-34s %.2f %.4f m/s %.4f %.4f m/s %.3f" % (label, cap, base.max_wheel_mps, base.scale, base.v_mps, base.left.duty))
the duty cap for first runs (Robotics Fundamentals 10 section 7): ask for 0.40 m/s and see what the robot does robot and curve cap max wheel scale measured duty believed curve, plain simulator 1.00 0.4000 m/s 1.0000 0.3993 m/s 1.000 believed curve, plain simulator 0.25 0.1000 m/s 0.2500 0.0991 m/s 0.250 measured curve, rig of section 3 1.00 0.4602 m/s 1.0000 0.4005 m/s 0.885 measured curve, rig of section 3 0.25 0.0678 m/s 0.1696 0.0681 m/s 0.250

With the cap at 0.25 the plain simulator gives 0.1000 m/s requested, 0.0991 measured; the motor rig, with its dead zone, gives 0.0678 m/s and a scale of 0.1696, meaning that the base scaled the command to 17 % of what was asked, along the same circle. Without the cap the rig’s measured curve needs only a duty of 0.885 for 0.40 m/s, because its top speed is 0.46. The cap is a ceiling on the duty; the speed it allows depends on the motors, so print it at start-up and read it.

Now the watchdog, and the numbers to design it with. The controller goes silent at t = 1.0 s, as a crash would, while the robot is driving at 0.3 m/s. After the timeout the base cuts the motors, and the robot then rolls on for about the motor’s time constant, so distance ≤ v × timeout + v × τ. With τ = 0.08 s (the simulator’s motor, an assumption on a real robot) this gives 0.3 × 0.25 + 0.3 × 0.08 = 0.099 m for a timeout of 0.25 s, and 0.054 m for 0.10 s. Robotics Fundamentals 10 section 7 computed 0.0525 m for its 100 ms watchdog, with a braking distance from a deceleration (0.0225 m) in place of the time constant (0.024 m): a different model of the same stop, and the same answer to within 2 mm.

def silent_after(sim, base, seconds, silent_at_s=1.0, v=0.3): """Drive at v, but the 'controller' stops calling set_velocity at silent_at_s (it has crashed). Returns (when the base first holds, or None; where the robot was when the controller went quiet).""" loop = FixedRateLoop(0.02, sim.now_s, sim.advance) first_hold = [] quiet_x = [] def step(dt_s): if sim.now_s() < silent_at_s: base.set_velocity(v, 0.0) elif not quiet_x: quiet_x.append(sim.pose()[0] - 1.0) base.update(dt_s) if base.hold and not first_hold: first_hold.append(sim.now_s()) loop.run(step, int(round(seconds / 0.02))) return (first_hold[0] if first_hold else None), quiet_x[0] print("the controller goes silent at t = 1.0 s while the robot drives at 0.3 m/s; after 4 s:") for timeout in (None, 0.25, 0.10): sim = Simulator(World(10.0, 4.0), seed=1, pose=(1.0, 2.0, 0.0)) base = DriveBase(sim, geom) if timeout is not None: CommandWatchdog(base, timeout) held_at, quiet_x = silent_after(sim, base, 4.0) gone = sim.pose()[0] - 1.0 if held_at is not None: print(" watchdog %.2f s: motors zeroed at t = %.2f s; the robot rolled %.3f m after the controller went quiet and stopped (hold = %r)" % (timeout, held_at, gone - quiet_x, base.hold)) else: print(" no watchdog: still driving at %.3f m/s, %.3f m further on, and it never stops" % (base.v_mps, gone - quiet_x)) print("the bound to design with: distance <= v x timeout + v x tau = 0.3 x timeout + 0.3 x 0.08: %.3f m for 0.25 s, %.3f m for 0.10 s" % (0.3 * 0.25 + 0.3 * 0.08, 0.3 * 0.10 + 0.3 * 0.08))
the controller goes silent at t = 1.0 s while the robot drives at 0.3 m/s; after 4 s: no watchdog: still driving at 0.300 m/s, 0.900 m further on, and it never stops watchdog 0.25 s: motors zeroed at t = 1.22 s; the robot rolled 0.090 m after the controller went quiet and stopped (hold = 'no command') watchdog 0.10 s: motors zeroed at t = 1.06 s; the robot rolled 0.042 m after the controller went quiet and stopped (hold = 'no command') the bound to design with: distance <= v x timeout + v x tau = 0.3 x timeout + 0.3 x 0.08: 0.099 m for 0.25 s, 0.054 m for 0.10 s

Without the watchdog the robot drives on, 0.9 m further in the 3 s that follow, and never stops. With 0.25 s the motors are zeroed at 1.22 s and the robot rolls 0.090 m; with 0.10 s, at 1.06 s and 0.042 m. The measurements sit below the bounds (0.099 and 0.054), as they should, since the bound counts the whole of τ. Notice that the distance shown is measured from when the controller went quiet, not from when the watchdog fired: the timeout is part of the distance. Challenge 3 asks for the timeout that meets a stopping budget, and for the cost of choosing it too short.

The two guards for the robot’s own sensing, with the failures injected at t = 1.0 s while driving at 0.3 m/s:

def drive_until_fault(hw, sim, seconds=3.0, stall=False): base = DriveBase(hw, geom) EncoderGuard(base) if stall: StallGuard(base) loop = FixedRateLoop(0.02, sim.now_s, sim.advance) first = [] def step(dt_s): base.set_velocity(0.3, 0.0) base.update(dt_s) if base.fault and not first: first.append((sim.now_s(), base.fault, sim.pose()[0] - 1.0)) loop.run(step, int(round(seconds / 0.02))) return base, first[0] if first else None print("faults injected at t = 1.0 s while driving at 0.3 m/s (the guards of this section are on):") sim = Simulator(World(10.0, 4.0), seed=1, pose=(1.0, 2.0, 0.0)) base, first = drive_until_fault(DeadEncoders(sim, 1.0), sim) print(" encoder bus dies: fault %r at t = %.2f s, %.3f m from the start; duty now %s, final x %.3f m" % (first[1], first[0], first[2], (base.left.duty, base.right.duty), sim.pose()[0] - 1.0)) for wheel in (0, 1): sim = Simulator(World(10.0, 4.0), seed=1, pose=(1.0, 2.0, 0.0)) base, first = drive_until_fault(StuckWheel(sim, wheel, 1.0), sim, stall=True) print(" %-5s wheel freezes: fault %r at t = %.2f s, %.3f m from the start; duty now %s, final x %.3f m" % ("left" if wheel == 0 else "right", first[1], first[0], first[2], (base.left.duty, base.right.duty), sim.pose()[0] - 1.0)) # a latched fault ignores commands until someone clears it log = [] base.set_velocity(0.3, 0.0) base.update(0.02) log.append(("fault latched, new command", (base.left.duty, base.right.duty))) base.clear_fault() base.update(0.02) log.append(("after clear_fault(), no command yet", (base.left.duty, base.right.duty))) base.set_velocity(0.3, 0.0) base.update(0.02) log.append(("after a new command", (base.left.duty, base.right.duty))) for what, duty in log: print(" %-38s duty (%.3f, %.3f)" % (what, *duty))
faults injected at t = 1.0 s while driving at 0.3 m/s (the guards of this section are on): encoder bus dies: fault 'encoder left bad' at t = 1.08 s, 0.275 m from the start; duty now (0.0, 0.0), final x 0.299 m left wheel freezes: fault 'stall left' at t = 1.42 s, 0.377 m from the start; duty now (0.0, 0.0), final x 0.401 m right wheel freezes: fault 'stall right' at t = 1.42 s, 0.377 m from the start; duty now (0.0, 0.0), final x 0.401 m fault latched, new command duty (0.000, 0.000) after clear_fault(), no command yet duty (0.000, 0.000) after a new command duty (0.080, 0.080)

A dead encoder bus is noticed in 0.08 s (5 failed reads at 20 ms and a little more: bad_max = 5) and the fault reads ‘encoder left bad’, with the robot 0.275 m from the start and the duty at zero; it then coasts for the 24 mm of section 2 to a final x of 0.299 m. A frozen wheel needs the 0.3 s of the stall rule and a little more (the speed estimate has to fall under the threshold first) and is noticed at 1.42 s, naming the wheel, after 0.377 m; the extra 0.12 s beyond the 0.3 s is, as I read the code, the time the filtered wheel speed needs to fall under the threshold, and I did not trace it further. The last three lines are the latch: with the fault set, a new command gives duty 0; after clear_fault(), still 0 (no command yet); and only after a new command do the wheels move (duty 0.080 for a command of 0.3 m/s, which at this stage is a ramp, not the final duty). The robot is not allowed to restart by itself after a fault.

A stop that does not raise

The last piece is the stop itself. stop() cancels the move, forgets the command, zeroes the motors, and tells the caller whether the hardware took the duty, instead of raising an exception. A safety path that can throw is a safety path that can be skipped by the exception handler above it. Here the driver of the motors stops answering exactly when the stop is called, twice:

class FlakyOutput: """Test rig: set_duty raises OSError for the first `fails` calls, as a driver on a bad connection would.""" def __init__(self, hw, fails): self.hw = hw self.fails = fails def set_duty(self, left, right): if self.fails > 0: self.fails -= 1 raise OSError("driver does not answer") self.hw.set_duty(left, right) def __getattr__(self, name): return getattr(self.hw, name) sim = Simulator(World(10.0, 4.0), seed=1, pose=(1.0, 2.0, 0.0)) base = DriveBase(FlakyOutput(sim, 0), geom) hold_velocity(sim, base, 0.3, 0.0, seconds=2.0) base.hw.fails = 2 # the driver stops answering just as we want to stop x0 = sim.pose()[0] print("stop() on a driver that fails twice:") for attempt in (1, 2, 3): ok = base.stop() for _ in range(5): sim.advance(0.02) print(" stop() #%d returned %-5s (send errors so far %d); wheel speed command now %s m/s, moved %.3f m since the first call" % (attempt, ok, base.send_errors, [round(w * 0.0325, 3) for w in sim.model.cmd_w], sim.pose()[0] - x0)) print("it never raised, it told the caller the truth, and the third call got through")
stop() on a driver that fails twice: stop() #1 returned False (send errors so far 1); wheel speed command now [0.3, 0.3] m/s, moved 0.030 m since the first call stop() #2 returned False (send errors so far 2); wheel speed command now [0.3, 0.3] m/s, moved 0.060 m since the first call stop() #3 returned True (send errors so far 2); wheel speed command now [0.0, 0.0] m/s, moved 0.077 m since the first call it never raised, it told the caller the truth, and the third call got through

The first two calls return False, count the errors and do not raise, while the simulated robot is still driving at 0.3 m/s (the duty it had is still in the driver, and the robot moved 0.030 m, then 0.060 m, in the 0.1 s after each call); the third gets through, the wheels’ command goes to zero and the robot has moved 0.077 m since the first call. A caller that gets False can retry, switch off the driver’s standby pin, or cut the power, as the E-stop of Robotics Fundamentals 10 section 7 does by hardware: the point is that the failure is reported.

12. Tests

The module has seventeen tests, named test_drive_..., run by the run_tests of Programming for Robotics 4 section 11, which takes a dictionary of test functions, runs them and lists the failures. They are written the way that chapter taught: small, deterministic, one fact each, with the plain simulator or the hardware-free IdealHardware as the world.

LayerWhat the tests check
Motorthe deadband map, including the cutoff and the cap; the ramp time equals delta / slew; stop() is instant and survives NaN and None
Speed curvea round trip from duty to speed and back; the motor and the curve agree
Kinematicssaturation keeps the turning radius; the numbers of Robotics Fundamentals 4 section 4
Odometrythe worked example of Robotics Fundamentals 4 section 5; a full circle closes; the heading wraps while the total turn does not
Movesthe trapezoid covers its distance; an open-loop move on ideal hardware; DriveDistance stops on odometry; TurnAngle with more than a full turn
Calibrationthe start-frame error; UMBmark recovers known errors
Safetythe watchdog zeroes the motors; an encoder failure latches until cleared; a frozen wheel is caught; stop() survives a failing bus
def _geom(): return Geometry(2 * math.pi * 0.0325 / 600, 2 * math.pi * 0.0325 / 600, 0.150, 0.40) # the build robot def _run(hw, base, seconds, command=None, dt_s=0.02): """A bare loop for the tests: command(t) may call set_velocity, then the world moves and the base updates.""" for _ in range(int(round(seconds / dt_s))): if command is not None: command(hw.now_s(), base) hw.advance(dt_s) base.update(dt_s) def test_drive_motor_deadband_map(): m = Motor(deadband=0.18) assert abs(m.duty_for(0.5) - 0.59) < 1e-12 and abs(m.duty_for(-0.5) + 0.59) < 1e-12 # the worked example of Programming for Robotics 2 section 2 assert m.duty_for(0.019) == 0.0 and m.duty_for(0.0) == 0.0 and m.duty_for(1.0) == 1.0 assert Motor(deadband=0.12, max_duty=0.25).duty_for(1.0) == 0.25 def test_drive_motor_ramp_time(): m = Motor(slew_per_s=2.0) # a change of 1.0 takes 0.5 s out = [m.set(1.0, 0.02) for _ in range(26)] assert out[23] < 1.0 and abs(out[24] - 1.0) < 1e-9 and out[25] == 1.0, out[22:] def test_drive_motor_stop_is_instant_and_garbage_safe(): m = Motor(deadband=0.12, slew_per_s=2.0) for _ in range(10): m.set(1.0, 0.02) assert m.duty > 0.0 and m.stop() == 0.0 and m.duty == 0.0 and m.cmd == 0.0 assert m.stop() == 0.0 # again: harmless for bad in (None, float("nan"), float("inf")): m.set(1.0, 0.02) assert m.set(bad, 0.02) == 0.0, bad def test_drive_curve_round_trip_and_motor_agree(): c = SpeedCurve(0.52, 0.12) for duty in (0.2, 0.5, 1.0): assert abs(c.duty_for(c.speed_at(duty)) - duty) < 1e-12 m = Motor(deadband=c.deadband) assert abs(m.duty_for(0.3 / c.max_speed_mps) - c.duty_for(0.3)) < 1e-12 assert c.speed_at(0.1) == 0.0 and c.speed_at(-0.5) == -c.speed_at(0.5) def test_drive_wheel_speeds_keep_the_radius(): left, right, scale = wheel_speeds(0.5, 3.0, 0.15, 0.6) # Robotics Fundamentals 4 section 4 assert abs(right - 0.6) < 1e-12 and abs(scale - 0.6 / 0.725) < 1e-12 and abs(left - 0.275 * scale) < 1e-12 v, w = body_speeds(left, right, 0.15) assert abs(v / w - 0.5 / 3.0) < 1e-12 # the same turning radius assert wheel_speeds(0.3, 0.0, 0.15) == (0.3, 0.3, 1.0) assert wheel_speeds(0.0, 2.0, 0.15)[0] == -0.15 def test_drive_odometry_worked_example(): o = Odometry(_geom()) o.update(0, 0) o.update(1200, 1500) # Robotics Fundamentals 4 section 5: x 0.4248, y 0.1504, 39.0 degrees assert abs(o.x - 0.4248) < 5e-5 and abs(o.y - 0.1504) < 5e-5 and abs(math.degrees(o.theta) - 39.0) < 1e-6 def test_drive_odometry_circle_closes_and_heading_wraps(): o = Odometry(_geom()) radius, track = 0.5, 0.150 n = 400 for _ in range(n): # a full circle, counter-clockwise, in n arcs o.step(2 * math.pi * (radius - track / 2) / n, 2 * math.pi * (radius + track / 2) / n) assert math.hypot(o.x, o.y) < 1e-9 and abs(o.turned_rad - 2 * math.pi) < 1e-9 and abs(o.theta) < 1e-9 def test_drive_trapezoid_covers_its_distance(): for total, v_max in ((1.0, 0.3), (0.05, 0.3)): # a trapezoid and a triangle p = Trapezoid(total, v_max, 0.6) n = 20000 covered = sum(p.speed((k + 0.5) * p.t_total / n) for k in range(n)) * p.t_total / n assert abs(covered - total) < 1e-4, (total, covered) def test_drive_open_loop_move_on_ideal_hardware(): hw = IdealHardware(_geom().mpt_left) base = DriveBase(hw, _geom()) base.start(OpenLoopMove(1.0, 0.3, 0.6)) _run(hw, base, 6.0) assert base.move is None and abs(base.odom.distance_m - 1.0) < 0.01, base.odom.distance_m def test_drive_distance_stops_on_odometry(): hw = IdealHardware(_geom().mpt_left, gain=(0.9, 0.9)) # a robot slower than it believes: only feedback copes base = DriveBase(hw, _geom()) move = DriveDistance(0.8) base.start(move) _run(hw, base, 10.0) assert move.done and move.failed is None and abs(base.odom.distance_m - 0.8) < 0.006, base.odom.distance_m def test_drive_turn_angle_more_than_a_full_turn(): hw = IdealHardware(_geom().mpt_left) base = DriveBase(hw, _geom()) base.start(TurnAngle(2.5 * math.pi)) _run(hw, base, 10.0) assert abs(base.odom.turned_rad - 2.5 * math.pi) < 0.02 and abs(wrap_pi(base.odom.theta - math.pi / 2)) < 0.02 def test_drive_watchdog_zeroes_the_motors(): hw = IdealHardware(_geom().mpt_left) base = DriveBase(hw, _geom()) CommandWatchdog(base, 0.25) _run(hw, base, 1.0, lambda t, b: b.set_velocity(0.3, 0.0) if t < 0.5 else None) # the commander goes silent at 0.5 s assert base.hold == "no command" and hw.duty == (0.0, 0.0) and base.fault is None base.set_velocity(0.3, 0.0) # it recovers when someone speaks again base.update(0.02) assert base.hold is None def test_drive_encoder_failure_latches_until_cleared(): hw = DeadEncoders(IdealHardware(_geom().mpt_left), 0.5) base = DriveBase(hw, _geom()) EncoderGuard(base) _run(hw, base, 1.0, lambda t, b: b.set_velocity(0.3, 0.0)) assert base.fault == "encoder left bad" and hw.duty == (0.0, 0.0) base.set_velocity(0.3, 0.0) base.update(0.02) assert hw.duty == (0.0, 0.0) # a latched fault ignores new commands base.clear_fault() assert base.fault is None and base.target == (0.0, 0.0) def test_drive_stall_guard_catches_a_frozen_wheel(): hw = StuckWheel(IdealHardware(_geom().mpt_left), 1, 0.5) base = DriveBase(hw, _geom()) StallGuard(base) _run(hw, base, 2.0, lambda t, b: b.set_velocity(0.3, 0.0)) assert base.fault == "stall right" and hw.duty == (0.0, 0.0) def test_drive_stop_survives_a_failing_bus(): class Flaky(IdealHardware): fails = 2 def set_duty(self, left, right): if self.fails > 0: self.fails -= 1 raise OSError("bus") IdealHardware.set_duty(self, left, right) hw = Flaky(_geom().mpt_left) base = DriveBase(hw, _geom()) assert base.stop() is False and base.send_errors == 1 # it did not raise assert base.stop() is False and base.stop() is True and hw.duty == (0.0, 0.0) def test_drive_start_frame_error(): got = start_frame_error((2.0, 3.0, math.pi / 2), (2.0, 4.0, 0.0)) # one metre ahead of a robot that faces +y assert abs(got[0] - 1.0) < 1e-12 and abs(got[1]) < 1e-12, got got = start_frame_error((0.0, 0.0, 0.0), (0.5, -0.2, 1.0)) assert abs(got[0] - 0.5) < 1e-12 and abs(got[1] + 0.2) < 1e-12, got def test_drive_umbmark_recovers_known_errors(): for phi, psi in ((0.02, -0.01), (0.13, -0.05), (-0.04, 0.03)): x_cw = square_end_model(2.0, phi, psi, False)[0] x_ccw = square_end_model(2.0, phi, psi, True)[0] got = umbmark_fit(2.0, x_cw, x_ccw) assert abs(got[0] - phi) < 1e-7 and abs(got[1] - psi) < 1e-7, (phi, psi, got) g, _, _ = umbmark_geometry(_geom(), 2.0, square_end_model(2.0, 0.0, 0.0, False)[0], square_end_model(2.0, 0.0, 0.0, True)[0]) assert abs(g.mpt_left - _geom().mpt_left) < 1e-12 and abs(g.track_m - 0.150) < 1e-12 # a perfect robot: nothing to correct if __name__ == "__main__": run_tests({n: f for n, f in globals().items() if n.startswith("test_drive_")})
failures = run_tests({n: f for n, f in globals().items() if n.startswith("test_drive_")})
ok test_drive_curve_round_trip_and_motor_agree ok test_drive_distance_stops_on_odometry ok test_drive_encoder_failure_latches_until_cleared ok test_drive_motor_deadband_map ok test_drive_motor_ramp_time ok test_drive_motor_stop_is_instant_and_garbage_safe ok test_drive_odometry_circle_closes_and_heading_wraps ok test_drive_odometry_worked_example ok test_drive_open_loop_move_on_ideal_hardware ok test_drive_stall_guard_catches_a_frozen_wheel ok test_drive_start_frame_error ok test_drive_stop_survives_a_failing_bus ok test_drive_trapezoid_covers_its_distance ok test_drive_turn_angle_more_than_a_full_turn ok test_drive_umbmark_recovers_known_errors ok test_drive_watchdog_zeroes_the_motors ok test_drive_wheel_speeds_keep_the_radius 17 tests, 0 failed

All seventeen pass. Two of the tests are worth a comment, because they are the kind that stay useful. test_drive_odometry_worked_example pins the code to a number written in another chapter, so that a change to the update formula that breaks Robotics Fundamentals 4 section 5 is noticed at once. test_drive_umbmark_recovers_known_errors goes the other way: it builds a robot from known phi and psi, produces its end points with the exact model, and checks that the fit finds them again: a test of a derivation, not only of code. One honest limit: the tests were not themselves tested by breaking the code on purpose (a “mutation test”), so I cannot say how many mistakes they would miss.

13. Running it on the real robot: notes and a bench-test list

Not run on hardware
Nothing in this section was run on a robot or a board. The MicroPython code is written from memory of the MicroPython documentation and of the data sheet of a TB6612-type motor driver, and was only checked for syntax with CPython. The pin names are those of the build robot in Programming for Robotics 2 section 10. Read the data sheet of your driver, check every pin against your wiring, and treat the code as a starting point. The truth table for the driver is the part I am least sure of.

The module needs a hardware object with ticks(), set_duty(left, right) and a clock, and optionally set_brake(on). The PicoHardware of Programming for Robotics 2 section 10 has the first two, with encoders counted by interrupts, but its motor channel applies an assumed dead zone of its own (command_to_duty of Programming for Robotics 2 section 2): Motor now owns the dead zone, and using both would count it twice, so the subclass below replaces the channel by one that applies no dead zone and takes a duty from −1 to +1. The pins are the build robot’s (GP2 and GP3 for the left encoder, GP4 and GP5 for the right, GP9 for the driver’s STBY, GP10 to GP12 for the left motor, GP13 to GP15 for the right), the PWM frequency is 20 kHz, above hearing.

# MicroPython on the build robot's Raspberry Pi Pico: from memory, NOT run on hardware, NOT run here (syntax-checked only) # Builds on PicoHardware and PIN_MAP of Programming for Robotics 2 section 10 (same pins, same encoder interrupts). from machine import Pin, PWM import time class RawChannel: """One motor on a TB6612-style driver. It takes a signed DUTY from -1 to +1 and applies NO deadband: that is Motor's job now. (MotorChannel of Programming for Robotics 2 applied an assumed 0.18 itself: using both would count the deadband twice.)""" def __init__(self, in1, in2, pwm, freq_hz=20000): self.in1, self.in2, self.pwm = in1, in2, pwm pwm.freq(freq_hz) self.coast() # the safe state first def set(self, duty): if duty == 0.0: self.coast() return self.in1.value(1 if duty > 0 else 0) # IN1 high, IN2 low: forward; the other way round: reverse self.in2.value(0 if duty > 0 else 1) self.pwm.duty_u16(int(min(1.0, abs(duty)) * 65535)) def coast(self): self.in1.value(0) # both inputs low: the outputs are off and the motor free-wheels (TB6612, from memory) self.in2.value(0) self.pwm.duty_u16(0) def brake(self): self.in1.value(1) # both inputs high: the motor terminals are shorted (short brake, from memory) self.in2.value(1) self.pwm.duty_u16(65535) class PicoDrive(PicoHardware): """PicoHardware plus what drive.py needs: raw-duty channels, a brake switch, and a clock in seconds.""" def __init__(self, cfg=Config, pins=PIN_MAP): PicoHardware.__init__(self, cfg, pins) # builds the encoders and sensors (and its own channels, replaced below) p = pins self.left = RawChannel(Pin(p["l_in1"], Pin.OUT, value=0), Pin(p["l_in2"], Pin.OUT, value=0), PWM(Pin(p["l_pwm"]))) self.right = RawChannel(Pin(p["r_in1"], Pin.OUT, value=0), Pin(p["r_in2"], Pin.OUT, value=0), PWM(Pin(p["r_pwm"]))) self._last_ms = time.ticks_ms() self._t_s = 0.0 def set_duty(self, left, right): self.left.set(left) self.right.set(right) self.stby.value(0 if (left == 0.0 and right == 0.0) else 1) # driver in standby when both motors are off def set_brake(self, on): """DriveBase calls this when both duties are zero: True = short the motors, False = let them coast.""" if on: self.left.brake() self.right.brake() self.stby.value(1) # the driver must be awake for the brake to act def now_s(self): now = time.ticks_ms() self._t_s += time.ticks_diff(now, self._last_ms) / 1000 # wrap-safe, as in Programming for Robotics 4 self._last_ms = now return self._t_s

For a TB6612-type driver, as I remember the data sheet: with IN1 high and IN2 low the motor turns one way, with the inputs swapped the other way, with both low the output is off and the motor coasts, with both high the terminals are shorted and the motor brakes, and STBY must be high for any of it to work. That is why brake() raises STBY and PicoDrive.set_duty lowers it only when both duties are zero. The main program then builds the drive, adds the guards, wraps the loop in a board watchdog and always ends with a stop:

# MicroPython on the build robot: from memory, NOT run on hardware, NOT run here (syntax-checked only) from machine import WDT hw = PicoDrive() geom = Geometry.from_config(Config) # then the numbers you MEASURED: mpt from the hand-turn test, the track from the squares base = DriveBase(hw, geom, max_duty=0.25, slew_per_s=4.0) # the first runs: a duty cap of 0.25 (add curves= once measured) CommandWatchdog(base, 0.25) EncoderGuard(base) StallGuard(base) wdt = WDT(timeout=1000) # the board resets if the loop stops feeding it: the pins then go back to inputs base.start(Sequence(DriveDistance(0.5, kp_heading=4.0), TurnAngle(1.5708), DriveDistance(0.5, kp_heading=4.0))) loop = FixedRateLoop(0.02, hw.now_s, time.sleep) def step(dt_s): base.update(dt_s) wdt.feed() if base.fault is not None: raise SystemExit(base.fault) # the motors are already off; stop the program and say why try: loop.run(step, 1500) # at most 30 s finally: base.stop() # whatever happened: motors off

Things to notice: the duty cap of 0.25 and the slew of 4 command units per second for the first run; the three guards; the board WDT, which is fed once per pass and resets the board, and with it the pins, if the loop stops; step raising SystemExit on a fault, after the base has already cut the motors; and the finally, which runs base.stop() whatever happened.

The bench-test list, in order

Do these in this order, each one before the next, as Robotics Fundamentals 10 section 7 and its test plan (section 9.8) say, and write down every number: they are what the code needs.

  1. Wheels off the ground, robot on a block, a current-limited supply if you have one. Nothing here moves the robot.
  2. Direction. Send a small positive duty to each wheel in turn (hw.set_duty(0.3, 0)): both must turn forward. A wheel that turns the wrong way is fixed in the hardware class (swap the two direction pins in the pin map), not by a minus sign in the maths.
  3. Encoder sign. Turn each wheel forward by hand: its count must increase. If it falls, swap that encoder’s A and B pins in the pin map. Do this before any motor test: a feedback sign that is wrong makes a controller run away.
  4. Ticks per revolution. Mark a wheel, turn it exactly ten times by hand, divide the count by ten: the build robot should give about 600 (Robotics Fundamentals 10 section 9.8, the encoder test). Convert to metres per tick with the measured wheel diameter; step 1 of section 10 then checks the result against a tape.
  5. Brake and coast. Run the wheels in the air at a duty of 0.25, cut the power, and time how long each wheel keeps turning with the driver off (coast) and with the brake. This gives the two time constants that section 2 assumed; take the time for the speed to fall to 37 % of its start as τ.
  6. The speed curve (section 3) on the floor, at the 0.25 cap and then in steps up, with a tape measure over a known distance; fit the deadband and the slope, left and right, and give DriveBase the curves.
  7. The lead. Run the sweep of section 7 at the speeds you will use: the best lead_s is about the time constant of the real motor plus the delays of the loop.
  8. The three calibration steps of section 10, with the heading hold off for the squares, then check with it on. Repeat and average.
  9. Watchdog and guards. Pull the encoder wire with the motors running (wheels in the air), stop the controller on purpose, and confirm each guard acts as the simulator showed, with the distance you measured. Only then raise the cap in steps.

14. The reference listing: drive.py

Here is the module in one piece: the blocks of this chapter without an output, joined in the order of the page. It needs filters.py of Programming for Robotics 4 next to it for its imports; the demonstrations also need the simulator. A __main__ at the end runs the seventeen tests.

# drive.py -- the driving code of Programming for Robotics 5 (standard library only) # Needs filters.py of Programming for Robotics 4 for SlewLimiter, SpeedEstimator, Sensor, Health, LinearCalibration, is_number and run_tests. # In one session those names already exist; saved as files, they are imported. import math try: from filters import SlewLimiter, SpeedEstimator, Sensor, Health, LinearCalibration, is_number, run_tests except ImportError: pass def clamp(value, low, high): return max(low, min(high, value)) def wrap_pi(angle): """Wrap an angle in radians to -pi .. +pi.""" return math.atan2(math.sin(angle), math.cos(angle)) class Motor: """One motor behind a command from -1 to +1 (a fraction of full speed): ramp, deadband compensation and a duty cap. set(cmd, dt_s) returns the signed duty to send (the sign is the direction). stop() is the always-safe way out.""" def __init__(self, deadband=0.0, slew_per_s=None, max_duty=1.0, cutoff=0.02, brake=False): assert max_duty > deadband, "a duty cap below the deadband would stop the robot from ever moving" self.deadband = deadband # the duty at which the motor really starts to turn (measured: section 3) self.max_duty = max_duty # a cap on |duty|: 0.25 for the first runs on a real robot self.cutoff = cutoff # a command smaller than this means 'stop', not 'creep' self.brake = brake # what stop() asks of the driver: True = brake, False = coast self.ramp = None if slew_per_s is not None: self.ramp = SlewLimiter(slew_per_s) # the slew limiter of Programming for Robotics 4, in command units per second self.ramp.value = 0.0 # start from rest, not from the first command self.cmd = 0.0 # the command after the ramp self.duty = 0.0 # the signed duty last returned def duty_for(self, cmd): """The static map from a command to a signed duty: deadband + (1 - deadband) |cmd|, capped; tiny commands give zero.""" mag = abs(cmd) if mag < self.cutoff: return 0.0 duty = min(self.max_duty, self.deadband + (1.0 - self.deadband) * mag) return duty if cmd > 0 else -duty def set(self, cmd, dt_s): if not is_number(cmd): return self.stop() # a NaN must never reach a motor cmd = clamp(cmd, -1.0, 1.0) if self.ramp is not None: cmd = self.ramp.update(cmd, dt_s) self.cmd = cmd self.duty = self.duty_for(cmd) return self.duty def stop(self): """Zero at once: no ramp, no argument, no state it depends on. Safe in any state, any number of times.""" self.cmd = 0.0 self.duty = 0.0 if self.ramp is not None: self.ramp.value = 0.0 return 0.0 class SpeedCurve: """The open-loop model of one motor: speed = k (duty - deadband) above the deadband, zero below it.""" def __init__(self, k_mps, deadband): self.k = k_mps # m/s per unit of duty above the deadband (the slope) self.deadband = deadband @property def max_speed_mps(self): return self.k * (1.0 - self.deadband) # the speed at duty 1.0 def speed_at(self, duty): mag = abs(duty) v = 0.0 if mag <= self.deadband else self.k * (mag - self.deadband) return v if duty >= 0 else -v def duty_for(self, speed_mps): if speed_mps == 0.0: return 0.0 mag = self.deadband + abs(speed_mps) / self.k return mag if speed_mps > 0 else -mag def measure_speed_curves(make_hw, mpt_left, mpt_right, duties, settle_s=1.0, measure_s=1.0, dt_s=0.02, min_mps=0.01): """Sweep the duty, each value on a fresh robot: let the speed settle, then measure it from the encoder counts. Returns (points, (left curve, right curve)); a point is (duty, left m/s, right m/s). Wheels that did not move are left out of the fit.""" points = [] for duty in duties: hw = make_hw() hw.set_duty(duty, duty) for _ in range(int(round(settle_s / dt_s))): hw.advance(dt_s) before = hw.ticks() for _ in range(int(round(measure_s / dt_s))): hw.advance(dt_s) after = hw.ticks() points.append((duty, (after[0] - before[0]) * mpt_left / measure_s, (after[1] - before[1]) * mpt_right / measure_s)) curves = [] for side in (1, 2): moving = [(p[0], p[side]) for p in points if p[side] >= min_mps] fit = LinearCalibration.fit([d for d, _ in moving], [v for _, v in moving]) # speed = gain x duty + offset curves.append(SpeedCurve(fit.gain, -fit.offset / fit.gain)) return points, tuple(curves) class RealMotors: """Test rig: motors that are not ideal. Per wheel, a deadband (no motion below it) and a gain on the speed above it. set_duty() maps the duty the way a real motor and driver would; everything else passes straight through.""" def __init__(self, hw, deadband=(0.0, 0.0), gain=(1.0, 1.0)): self.hw = hw self.deadband = deadband self.gain = gain def set_duty(self, left, right): out = [] for duty, db, g in zip((left, right), self.deadband, self.gain): mag = abs(duty) effective = 0.0 if mag <= db else g * (mag - db) / (1.0 - db) out.append(math.copysign(effective, duty)) self.hw.set_duty(out[0], out[1]) def __getattr__(self, name): return getattr(self.hw, name) class Geometry: """The robot's dimensions as the SOFTWARE believes them: the numbers that calibration corrects.""" def __init__(self, mpt_left, mpt_right, track_m, max_speed_mps): self.mpt_left = mpt_left # metres of ground per encoder tick, per wheel self.mpt_right = mpt_right self.track_m = track_m # the effective distance between the wheel contact points self.max_speed_mps = max_speed_mps # the believed wheel speed at duty 1.0 @classmethod def from_config(cls, cfg): return cls(cfg.METRES_PER_TICK, cfg.METRES_PER_TICK, cfg.TRACK_WIDTH_M, cfg.MAX_SPEED_MPS) def scaled(self, left=1.0, right=1.0, track=1.0): """A copy with each number multiplied by a correction factor.""" return Geometry(self.mpt_left * left, self.mpt_right * right, self.track_m * track, self.max_speed_mps) def __repr__(self): return "Geometry(mpt_left=%.6f mm, mpt_right=%.6f mm, track=%.2f mm)" % (self.mpt_left * 1e3, self.mpt_right * 1e3, self.track_m * 1e3) def wheel_speeds(v_mps, omega_rps, track_m, max_wheel_mps=None): """Inverse kinematics (Robotics Fundamentals 4 section 4): returns (left, right, scale). If a wheel would need more than max_wheel_mps, BOTH are scaled by the same factor, so the turning radius survives.""" half = 0.5 * omega_rps * track_m left, right = v_mps - half, v_mps + half scale = 1.0 if max_wheel_mps is not None: top = max(abs(left), abs(right)) if top > max_wheel_mps: scale = max_wheel_mps / top left, right = left * scale, right * scale return left, right, scale def body_speeds(left_mps, right_mps, track_m): """Forward kinematics (Robotics Fundamentals 4 section 3): the wheel speeds to (v, omega), counter-clockwise positive.""" return 0.5 * (left_mps + right_mps), (right_mps - left_mps) / track_m def pose_step(x, y, theta, ds, dtheta, method="arc"): """Move a pose by the centre travel ds and the turn dtheta: the update of Robotics Fundamentals 4 section 5.""" if method == "midpoint" or abs(dtheta) < 1e-9: x += ds * math.cos(theta + dtheta / 2) # the heading half way through (and 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 Odometry: """Dead reckoning from the two wheels' tick totals (already unwrapped): x forward, y left, counter-clockwise positive.""" def __init__(self, geom, pose=(0.0, 0.0, 0.0), method="arc"): self.geom = geom self.x, self.y, self.theta = pose self.method = method self.last = None # the tick totals at the previous update self.distance_m = 0.0 # the signed distance the centre has travelled, summed self.turned_rad = 0.0 # the total turn, NOT wrapped: two turns on the spot are 12.57 rad def update(self, left_total, right_total): if self.last is None: # the first reading only sets the reference self.last = (left_total, right_total) return 0.0, 0.0 d_left = (left_total - self.last[0]) * self.geom.mpt_left d_right = (right_total - self.last[1]) * self.geom.mpt_right self.last = (left_total, right_total) return self.step(d_left, d_right) def step(self, d_left, d_right): """Apply the distances the two wheels rolled since the last update.""" ds = 0.5 * (d_left + d_right) dtheta = (d_right - d_left) / self.geom.track_m self.x, self.y, self.theta = pose_step(self.x, self.y, self.theta, ds, dtheta, self.method) self.distance_m += ds self.turned_rad += dtheta return ds, dtheta @property def pose(self): return (self.x, self.y, self.theta) class DriveBase: """The robot's body: set_velocity(v, omega) in, duty cycles out, a pose estimate and wheel speeds kept up to date. Call update(dt_s) once per pass of the loop. Guards (section 11) watch for trouble; a Move (section 7) can be started.""" def __init__(self, hw, geom, curves=None, max_duty=1.0, slew_per_s=4.0, brake=False, window=5, alpha=0.5): self.hw = hw # the three methods of Programming for Robotics 1 section 7: ticks(), set_duty() self.geom = geom if curves is None: # nothing measured yet: duty 1.0 is the believed full speed, no deadband believed = SpeedCurve(geom.max_speed_mps, 0.0) curves = (believed, believed) self.curves = curves self.left = Motor(curves[0].deadband, slew_per_s, max_duty, brake=brake) self.right = Motor(curves[1].deadband, slew_per_s, max_duty, brake=brake) self.max_wheel_mps = min(c.speed_at(max_duty) for c in curves) # what the slower wheel can do at the duty cap self.est_l = SpeedEstimator(geom.mpt_left, window, alpha) self.est_r = SpeedEstimator(geom.mpt_right, window, alpha) self.odom = Odometry(geom) self.t_s = 0.0 # robot time: the sum of the measured dt values self.target = (0.0, 0.0) # the wanted (v, omega) self.last_cmd_t = None # when set_velocity was last called self.move = None self.guards = [] # objects with check(base, dt_s); creating one switches it on self.fault = None # a latched fault: the motors stay off until clear_fault() self.hold = None # a reason to stand still for now (not latched) self.raw_ticks = (None, None) # None: the read failed this pass self.wheel_mps = (0.0, 0.0) # filtered wheel speeds from the encoders self.wanted_mps = (0.0, 0.0) # what the motors were last told, after the ramp self.scale = 1.0 # < 1 when the wheel-speed limit made set_velocity scale both wheels self.send_ok = True self.send_errors = 0 @property def pose(self): return self.odom.pose @property def v_mps(self): return 0.5 * (self.wheel_mps[0] + self.wheel_mps[1]) @property def omega_rps(self): return (self.wheel_mps[1] - self.wheel_mps[0]) / self.geom.track_m def set_velocity(self, v_mps, omega_rps): """The one command: forward speed in m/s, turn rate in rad/s (counter-clockwise positive). Call it every pass.""" if not (is_number(v_mps) and is_number(omega_rps)): self.target = (0.0, 0.0) # garbage in, stop out return self.target = (v_mps, omega_rps) self.last_cmd_t = self.t_s def start(self, move): self.move = move def clear_fault(self): """The deliberate act that lets a latched fault go. The robot does not move until the next set_velocity.""" self.fault = None self.target = (0.0, 0.0) self.last_cmd_t = None def stop(self): """Always safe: cancel the move, forget the command, zero both motors now. Returns True if the hardware took it.""" self.move = None self.target = (0.0, 0.0) self._cut() return self.send_ok def update(self, dt_s): self.t_s += dt_s self._sense() if self.move is not None and self.fault is None: v, w = self.move.update(self, dt_s) self.set_velocity(v, w) # the move is the commander: it counts as a command if self.move.done: self.move = None self._check_guards(dt_s) if self.fault is not None or self.hold is not None: self._cut() return self._drive(dt_s) # ---- the parts of one pass ---- def _sense(self): try: self.raw_ticks = tuple(self.hw.ticks()) except OSError: # a bus error: no reading this pass, not a crash self.raw_ticks = (None, None) left, right = self.raw_ticks if left is not None and right is not None: self.wheel_mps = (self.est_l.update(left, self.t_s), self.est_r.update(right, self.t_s)) self.odom.update(self.est_l.total, self.est_r.total) def _check_guards(self, dt_s): self.hold = None for guard in self.guards: reason = guard.check(self, dt_s) if reason is None: continue if guard.latching: if self.fault is None: self.fault = reason # the first cause wins, and stays until clear_fault() else: self.hold = reason def _drive(self, dt_s): v, w = self.target left, right, self.scale = wheel_speeds(v, w, self.geom.track_m, self.max_wheel_mps) left_duty = self.left.set(left / self.curves[0].max_speed_mps, dt_s) right_duty = self.right.set(right / self.curves[1].max_speed_mps, dt_s) self.wanted_mps = (self.left.cmd * self.curves[0].max_speed_mps, self.right.cmd * self.curves[1].max_speed_mps) self._send(left_duty, right_duty) def _cut(self): if self.fault is not None: self.move = None self.target = (0.0, 0.0) self.left.stop() self.right.stop() self.wanted_mps = (0.0, 0.0) self._send(0.0, 0.0) def _send(self, left_duty, right_duty): try: self.hw.set_duty(left_duty, right_duty) self.send_ok = True except OSError: self.send_ok = False self.send_errors += 1 set_brake = getattr(self.hw, "set_brake", None) # an optional extra method of the hardware object if set_brake is not None and left_duty == 0.0 and right_duty == 0.0: set_brake(self.left.brake) class Move: """A motion written as a step function: update() is called once per pass and returns (v_mps, omega_rps). It never waits. done turns True on the pass that finishes it; failed says why it gave up (or None).""" done = False failed = None def update(self, base, dt_s): raise NotImplementedError class Trapezoid: """A speed profile: accelerate at `accel`, cruise at v_max, brake at `accel`, covering exactly `total` (positive). When total is too short to reach v_max it becomes a triangle with a lower peak.""" def __init__(self, total, v_max, accel): self.total = total self.v_max = min(v_max, math.sqrt(total * accel)) # the triangle's peak, if the trapezoid does not fit self.accel = accel self.t_acc = self.v_max / accel d_acc = 0.5 * accel * self.t_acc ** 2 self.t_cruise = (total - 2 * d_acc) / self.v_max self.t_total = 2 * self.t_acc + self.t_cruise def speed(self, t_s): if t_s <= 0.0 or t_s >= self.t_total: return 0.0 if t_s < self.t_acc: return self.accel * t_s if t_s < self.t_acc + self.t_cruise: return self.v_max return self.accel * (self.t_total - t_s) class OpenLoopMove(Move): """Follows a Trapezoid by the clock alone: no encoders, no odometry. It is only as good as the speed curve behind it. turn=False drives `total` metres (negative: backwards); turn=True turns `total` radians on the spot.""" def __init__(self, total, v_max, accel, turn=False): self.sign = 1.0 if total >= 0 else -1.0 self.profile = Trapezoid(abs(total), v_max, accel) self.turn = turn self.t_s = 0.0 def update(self, base, dt_s): self.t_s += dt_s if self.t_s >= self.profile.t_total: self.done = True return 0.0, 0.0 speed = self.sign * self.profile.speed(self.t_s) return (0.0, speed) if self.turn else (speed, 0.0) class _Approach(Move): """The closed-loop part shared by DriveDistance and TurnAngle. Each pass it measures the distance still to go, asks for the speed from which it could still stop in time, sqrt(2 a r), and never more than v_max or `accel` faster than a pass before. lead_s is the lag of the motors: the distance they will still cover after the command is gone is taken off r.""" def __init__(self, total, v_max, accel, lead_s, tol, settle, creep, limit_s): self.total = total self.sign = 1.0 if total >= 0 else -1.0 self.v_max = v_max self.accel = accel self.lead_s = lead_s self.tol = tol self.settle = settle # speed below which the robot counts as stopped self.creep = creep # the slowest speed worth commanding while still short of the goal (a motor needs a push) self.limit_s = limit_s # give up after this long (None: never) self.start = None self.t_s = 0.0 self.speed = 0.0 # the commanded speed, always >= 0 (the sign is applied on the way out) self.error = None # what was still to go when it finished: the final error def update(self, base, dt_s): if self.start is None: self.start = self._progress(base) self._begin(base) self.t_s += dt_s remaining = self.sign * (self.total - (self._progress(base) - self.start)) r = remaining - self.speed * self.lead_s target = min(self.v_max, math.sqrt(2.0 * self.accel * max(r, 0.0))) if remaining > self.tol: target = max(target, self.creep) # not yet there: never ask for less than a creep self.speed = min(target, self.speed + self.accel * dt_s) # speeding up is limited, slowing down follows the profile if remaining <= self.tol and abs(self._measured(base)) < self.settle: self.done = True self.error = remaining return 0.0, 0.0 if self.limit_s is not None and self.t_s > self.limit_s: self.done, self.failed, self.error = True, "timeout", remaining return 0.0, 0.0 return self._output(base) def _begin(self, base): pass class DriveDistance(_Approach): """Drive `distance_m` straight (negative: backwards). Stops by odometry. kp_heading > 0 holds the starting heading.""" def __init__(self, distance_m, v_max=0.30, accel=0.6, kp_heading=0.0, lead_s=0.20, tol_m=0.003, settle_mps=0.02, creep_mps=0.015, limit_s=None): _Approach.__init__(self, distance_m, v_max, accel, lead_s, tol_m, settle_mps, creep_mps, limit_s) self.kp_heading = kp_heading # rad/s of turn per rad of heading error def _progress(self, base): return base.odom.distance_m def _measured(self, base): return base.v_mps def _begin(self, base): self.theta0 = base.odom.theta # the heading to hold def _output(self, base): omega = self.kp_heading * wrap_pi(self.theta0 - base.odom.theta) return self.sign * self.speed, omega class TurnAngle(_Approach): """Turn `angle_rad` on the spot (counter-clockwise positive; more than a full turn is fine). Stops by odometry.""" def __init__(self, angle_rad, w_max=1.5, alpha=3.0, lead_s=0.20, tol_rad=0.01, settle_rps=0.05, creep_rps=0.15, limit_s=None): _Approach.__init__(self, angle_rad, w_max, alpha, lead_s, tol_rad, settle_rps, creep_rps, limit_s) def _progress(self, base): return base.odom.turned_rad def _measured(self, base): return base.omega_rps def _output(self, base): return 0.0, self.sign * self.speed class Sequence(Move): """Moves one after the other. A move's last pass returns (0, 0); the next move starts on the following pass.""" def __init__(self, *moves): self.moves = list(moves) self.i = 0 def update(self, base, dt_s): if self.i < len(self.moves) and self.moves[self.i].done: self.i += 1 if self.i >= len(self.moves): self.done = True return 0.0, 0.0 out = self.moves[self.i].update(base, dt_s) if self.moves[self.i].failed is not None: self.done, self.failed = True, self.moves[self.i].failed # one failed move ends the sequence return out def square(side_m, ccw=True, kp_heading=0.0): """Four sides and four corners: the test drive of section 8 and the calibration run of section 10.""" corner = math.pi / 2 if ccw else -math.pi / 2 moves = [] for _ in range(4): moves.append(DriveDistance(side_m, kp_heading=kp_heading)) moves.append(TurnAngle(corner)) return Sequence(*moves) class PathLog: """The true path and the odometry's, one point per pass, both in the world frame (the odometry starts at the robot's start pose).""" def __init__(self, start_pose): self.start = start_pose self.truth = [] self.odom = [] def add(self, truth_pose, odom_pose): self.truth.append((truth_pose[0], truth_pose[1])) x, y, _ = odom_pose c, s = math.cos(self.start[2]), math.sin(self.start[2]) self.odom.append((self.start[0] + c * x - s * y, self.start[1] + s * x + c * y)) def path_overlay(truth, odom, cols=60, margin_m=0.05): """Two paths from above on a character grid (x to the right, y up): * the true path, o the odometry's, @ both in one cell, S the start, E the true end, e the odometry's end.""" pts = truth + odom x0, x1 = min(p[0] for p in pts) - margin_m, max(p[0] for p in pts) + margin_m y0, y1 = min(p[1] for p in pts) - margin_m, max(p[1] for p in pts) + margin_m cell_w = (x1 - x0) / (cols - 1) cell_h = 2.0 * cell_w # a character is about twice as tall as it is wide rows = int((y1 - y0) / cell_h) + 1 grid = [[" "] * cols for _ in range(rows)] def put(p, ch, over=False): c = int((p[0] - x0) / cell_w) r = rows - 1 - int((p[1] - y0) / cell_h) old = grid[r][c] if over or old == " ": grid[r][c] = ch elif old in "*o" and ch in "*o" and old != ch: grid[r][c] = "@" # both paths in one cell for p in truth: put(p, "*") for p in odom: put(p, "o") put(truth[0], "S", True) put(odom[-1], "e", True) put(truth[-1], "E", True) return "\n".join("".join(row).rstrip() for row in grid) def start_frame_error(start_pose, end_pose): """The end point's offset from the start point in the START's own frame: x ahead of where the robot began, y to its left. On a real robot this is what a tape measure gives you; in the simulator it comes from the true pose.""" dx, dy = end_pose[0] - start_pose[0], end_pose[1] - start_pose[1] c, s = math.cos(start_pose[2]), math.sin(start_pose[2]) return c * dx + s * dy, -s * dx + c * dy def square_end_model(side_m, phi, psi, ccw): """Where a square of four sides ends, from two numbers: phi = the heading each straight side adds (rad, counter-clockwise positive: a wheel mismatch) and psi = how much each corner turns more than 90 degrees (rad: a track error). Returns (x, y) in the start frame.""" x, y, theta = 0.0, 0.0, 0.0 corner = (math.pi / 2 + psi) * (1.0 if ccw else -1.0) for _ in range(4): x, y, theta = pose_step(x, y, theta, side_m, phi) theta = wrap_pi(theta + corner) return x, y def umbmark_fit(side_m, x_cw, x_ccw): """Find (phi, psi) from the x end-point errors of a clockwise and a counter-clockwise square. Starts from the first-order answer psi = (x_ccw + x_cw) / 4L, phi = (x_ccw - x_cw) / 4L and refines it with Newton steps on square_end_model.""" phi = (x_ccw - x_cw) / (4.0 * side_m) psi = (x_ccw + x_cw) / (4.0 * side_m) for _ in range(20): f1 = square_end_model(side_m, phi, psi, True)[0] - x_ccw f2 = square_end_model(side_m, phi, psi, False)[0] - x_cw if abs(f1) + abs(f2) < 1e-10: break h = 1e-6 a1 = (square_end_model(side_m, phi + h, psi, True)[0] - x_ccw - f1) / h b1 = (square_end_model(side_m, phi, psi + h, True)[0] - x_ccw - f1) / h a2 = (square_end_model(side_m, phi + h, psi, False)[0] - x_cw - f2) / h b2 = (square_end_model(side_m, phi, psi + h, False)[0] - x_cw - f2) / h det = a1 * b2 - a2 * b1 phi -= (b2 * f1 - b1 * f2) / det psi -= (a1 * f2 - a2 * f1) / det return phi, psi def umbmark_geometry(geom, side_m, x_cw, x_ccw): """The corrected Geometry from one clockwise and one counter-clockwise square. Returns (geometry, phi, psi). The track is corrected from psi, then the wheel ratio from phi; the average wheel scale is left alone (the straight-line test sets it).""" phi, psi = umbmark_fit(side_m, x_cw, x_ccw) track = geom.track_m / (1.0 + 2.0 * psi / math.pi) # a corner turns (90 deg x believed track / true track) delta = phi * track / side_m # right wheel's ground distance / left's - 1, as odometry sees it now c_left = 2.0 / (2.0 + delta) c_right = 2.0 * (1.0 + delta) / (2.0 + delta) # c_right / c_left = 1 + delta, and their mean is 1 return geom.scaled(c_left, c_right, track / geom.track_m), phi, psi class CommandWatchdog: """Not latching: if no set_velocity (or Move) has spoken for timeout_s, the motors are zeroed until one speaks again.""" latching = False def __init__(self, base, timeout_s=0.25): self.timeout_s = timeout_s base.guards.append(self) # creating a guard switches it on def check(self, base, dt_s): if base.last_cmd_t is None or base.t_s - base.last_cmd_t > self.timeout_s: return "no command" return None class EncoderGuard: """Latching: the encoder reads fail (bad) or stop arriving (stale), as Health of Programming for Robotics 4 defines them. A counter that stays put is normal for a robot at rest, so 'stuck' is switched off here; StallGuard covers 'driven but not moving'.""" latching = True def __init__(self, base, bad_max=5, timeout_s=0.11): clock = lambda: base.t_s self.sensors = [Sensor(lambda i=i: base.raw_ticks[i], clock, -2 ** 40, 2 ** 40, timeout_s, name=name) for i, name in enumerate(("left", "right"))] self.health = [Health(s, stuck_n=10 ** 9, bad_max=bad_max) for s in self.sensors] base.guards.append(self) def check(self, base, dt_s): for sensor, health in zip(self.sensors, self.health): sensor.read() fault = health.update() if fault is not None: return "encoder %s %s" % (sensor.name, fault) return None class StallGuard: """Latching: a wheel that is told to move at least min_mps but measures under `fraction` of that, for time_s, is stalled (a jammed wheel, a broken encoder wire): the rule of Robotics Fundamentals 10 section 7, in code.""" latching = True def __init__(self, base, min_mps=0.04, fraction=0.1, time_s=0.3): self.min_mps = min_mps self.fraction = fraction self.time_s = time_s self.timers = [0.0, 0.0] base.guards.append(self) def check(self, base, dt_s): for i, name in enumerate(("left", "right")): wanted, measured = abs(base.wanted_mps[i]), abs(base.wheel_mps[i]) if wanted >= self.min_mps and measured < self.fraction * wanted: self.timers[i] += dt_s else: self.timers[i] = 0.0 if self.timers[i] >= self.time_s: return "stall %s" % name return None class DeadEncoders: """Test rig: from fail_after_s on, ticks() raises OSError, like a bus that has died.""" def __init__(self, hw, fail_after_s): self.hw = hw self.fail_after_s = fail_after_s def ticks(self): if self.hw.now_s() >= self.fail_after_s: raise OSError("encoder bus timeout") return self.hw.ticks() def __getattr__(self, name): return getattr(self.hw, name) class StuckWheel: """Test rig: from after_s on, one wheel's tick count freezes (index 0 = left), as with a jammed wheel or a broken wire.""" def __init__(self, hw, wheel, after_s): self.hw = hw self.wheel = wheel self.after_s = after_s self.frozen = None def ticks(self): ticks = list(self.hw.ticks()) if self.hw.now_s() >= self.after_s: if self.frozen is None: self.frozen = ticks[self.wheel] ticks[self.wheel] = self.frozen return tuple(ticks) def __getattr__(self, name): return getattr(self.hw, name) class IdealHardware: """Test rig that needs no simulator: wheels that follow the duty exactly (no lag, no noise), whole-count ticks, a clock. gain scales each wheel's speed (a weaker motor); the hardware interface is ticks(), set_duty(), now_s() and advance().""" def __init__(self, mpt_m, top_mps=0.40, gain=(1.0, 1.0)): self.mpt_m = mpt_m self.top_mps = top_mps self.gain = gain self.duty = (0.0, 0.0) self.commands = [] # every set_duty call, for the tests to read self.t_s = 0.0 self._ticks = [0.0, 0.0] def set_duty(self, left, right): self.duty = (clamp(left, -1.0, 1.0), clamp(right, -1.0, 1.0)) self.commands.append(self.duty) def ticks(self): return int(self._ticks[0]), int(self._ticks[1]) def now_s(self): return self.t_s def advance(self, dt_s): for i in (0, 1): self._ticks[i] += self.duty[i] * self.gain[i] * self.top_mps * dt_s / self.mpt_m self.t_s += dt_s def _geom(): return Geometry(2 * math.pi * 0.0325 / 600, 2 * math.pi * 0.0325 / 600, 0.150, 0.40) # the build robot def _run(hw, base, seconds, command=None, dt_s=0.02): """A bare loop for the tests: command(t) may call set_velocity, then the world moves and the base updates.""" for _ in range(int(round(seconds / dt_s))): if command is not None: command(hw.now_s(), base) hw.advance(dt_s) base.update(dt_s) def test_drive_motor_deadband_map(): m = Motor(deadband=0.18) assert abs(m.duty_for(0.5) - 0.59) < 1e-12 and abs(m.duty_for(-0.5) + 0.59) < 1e-12 # the worked example of Programming for Robotics 2 section 2 assert m.duty_for(0.019) == 0.0 and m.duty_for(0.0) == 0.0 and m.duty_for(1.0) == 1.0 assert Motor(deadband=0.12, max_duty=0.25).duty_for(1.0) == 0.25 def test_drive_motor_ramp_time(): m = Motor(slew_per_s=2.0) # a change of 1.0 takes 0.5 s out = [m.set(1.0, 0.02) for _ in range(26)] assert out[23] < 1.0 and abs(out[24] - 1.0) < 1e-9 and out[25] == 1.0, out[22:] def test_drive_motor_stop_is_instant_and_garbage_safe(): m = Motor(deadband=0.12, slew_per_s=2.0) for _ in range(10): m.set(1.0, 0.02) assert m.duty > 0.0 and m.stop() == 0.0 and m.duty == 0.0 and m.cmd == 0.0 assert m.stop() == 0.0 # again: harmless for bad in (None, float("nan"), float("inf")): m.set(1.0, 0.02) assert m.set(bad, 0.02) == 0.0, bad def test_drive_curve_round_trip_and_motor_agree(): c = SpeedCurve(0.52, 0.12) for duty in (0.2, 0.5, 1.0): assert abs(c.duty_for(c.speed_at(duty)) - duty) < 1e-12 m = Motor(deadband=c.deadband) assert abs(m.duty_for(0.3 / c.max_speed_mps) - c.duty_for(0.3)) < 1e-12 assert c.speed_at(0.1) == 0.0 and c.speed_at(-0.5) == -c.speed_at(0.5) def test_drive_wheel_speeds_keep_the_radius(): left, right, scale = wheel_speeds(0.5, 3.0, 0.15, 0.6) # Robotics Fundamentals 4 section 4 assert abs(right - 0.6) < 1e-12 and abs(scale - 0.6 / 0.725) < 1e-12 and abs(left - 0.275 * scale) < 1e-12 v, w = body_speeds(left, right, 0.15) assert abs(v / w - 0.5 / 3.0) < 1e-12 # the same turning radius assert wheel_speeds(0.3, 0.0, 0.15) == (0.3, 0.3, 1.0) assert wheel_speeds(0.0, 2.0, 0.15)[0] == -0.15 def test_drive_odometry_worked_example(): o = Odometry(_geom()) o.update(0, 0) o.update(1200, 1500) # Robotics Fundamentals 4 section 5: x 0.4248, y 0.1504, 39.0 degrees assert abs(o.x - 0.4248) < 5e-5 and abs(o.y - 0.1504) < 5e-5 and abs(math.degrees(o.theta) - 39.0) < 1e-6 def test_drive_odometry_circle_closes_and_heading_wraps(): o = Odometry(_geom()) radius, track = 0.5, 0.150 n = 400 for _ in range(n): # a full circle, counter-clockwise, in n arcs o.step(2 * math.pi * (radius - track / 2) / n, 2 * math.pi * (radius + track / 2) / n) assert math.hypot(o.x, o.y) < 1e-9 and abs(o.turned_rad - 2 * math.pi) < 1e-9 and abs(o.theta) < 1e-9 def test_drive_trapezoid_covers_its_distance(): for total, v_max in ((1.0, 0.3), (0.05, 0.3)): # a trapezoid and a triangle p = Trapezoid(total, v_max, 0.6) n = 20000 covered = sum(p.speed((k + 0.5) * p.t_total / n) for k in range(n)) * p.t_total / n assert abs(covered - total) < 1e-4, (total, covered) def test_drive_open_loop_move_on_ideal_hardware(): hw = IdealHardware(_geom().mpt_left) base = DriveBase(hw, _geom()) base.start(OpenLoopMove(1.0, 0.3, 0.6)) _run(hw, base, 6.0) assert base.move is None and abs(base.odom.distance_m - 1.0) < 0.01, base.odom.distance_m def test_drive_distance_stops_on_odometry(): hw = IdealHardware(_geom().mpt_left, gain=(0.9, 0.9)) # a robot slower than it believes: only feedback copes base = DriveBase(hw, _geom()) move = DriveDistance(0.8) base.start(move) _run(hw, base, 10.0) assert move.done and move.failed is None and abs(base.odom.distance_m - 0.8) < 0.006, base.odom.distance_m def test_drive_turn_angle_more_than_a_full_turn(): hw = IdealHardware(_geom().mpt_left) base = DriveBase(hw, _geom()) base.start(TurnAngle(2.5 * math.pi)) _run(hw, base, 10.0) assert abs(base.odom.turned_rad - 2.5 * math.pi) < 0.02 and abs(wrap_pi(base.odom.theta - math.pi / 2)) < 0.02 def test_drive_watchdog_zeroes_the_motors(): hw = IdealHardware(_geom().mpt_left) base = DriveBase(hw, _geom()) CommandWatchdog(base, 0.25) _run(hw, base, 1.0, lambda t, b: b.set_velocity(0.3, 0.0) if t < 0.5 else None) # the commander goes silent at 0.5 s assert base.hold == "no command" and hw.duty == (0.0, 0.0) and base.fault is None base.set_velocity(0.3, 0.0) # it recovers when someone speaks again base.update(0.02) assert base.hold is None def test_drive_encoder_failure_latches_until_cleared(): hw = DeadEncoders(IdealHardware(_geom().mpt_left), 0.5) base = DriveBase(hw, _geom()) EncoderGuard(base) _run(hw, base, 1.0, lambda t, b: b.set_velocity(0.3, 0.0)) assert base.fault == "encoder left bad" and hw.duty == (0.0, 0.0) base.set_velocity(0.3, 0.0) base.update(0.02) assert hw.duty == (0.0, 0.0) # a latched fault ignores new commands base.clear_fault() assert base.fault is None and base.target == (0.0, 0.0) def test_drive_stall_guard_catches_a_frozen_wheel(): hw = StuckWheel(IdealHardware(_geom().mpt_left), 1, 0.5) base = DriveBase(hw, _geom()) StallGuard(base) _run(hw, base, 2.0, lambda t, b: b.set_velocity(0.3, 0.0)) assert base.fault == "stall right" and hw.duty == (0.0, 0.0) def test_drive_stop_survives_a_failing_bus(): class Flaky(IdealHardware): fails = 2 def set_duty(self, left, right): if self.fails > 0: self.fails -= 1 raise OSError("bus") IdealHardware.set_duty(self, left, right) hw = Flaky(_geom().mpt_left) base = DriveBase(hw, _geom()) assert base.stop() is False and base.send_errors == 1 # it did not raise assert base.stop() is False and base.stop() is True and hw.duty == (0.0, 0.0) def test_drive_start_frame_error(): got = start_frame_error((2.0, 3.0, math.pi / 2), (2.0, 4.0, 0.0)) # one metre ahead of a robot that faces +y assert abs(got[0] - 1.0) < 1e-12 and abs(got[1]) < 1e-12, got got = start_frame_error((0.0, 0.0, 0.0), (0.5, -0.2, 1.0)) assert abs(got[0] - 0.5) < 1e-12 and abs(got[1] + 0.2) < 1e-12, got def test_drive_umbmark_recovers_known_errors(): for phi, psi in ((0.02, -0.01), (0.13, -0.05), (-0.04, 0.03)): x_cw = square_end_model(2.0, phi, psi, False)[0] x_ccw = square_end_model(2.0, phi, psi, True)[0] got = umbmark_fit(2.0, x_cw, x_ccw) assert abs(got[0] - phi) < 1e-7 and abs(got[1] - psi) < 1e-7, (phi, psi, got) g, _, _ = umbmark_geometry(_geom(), 2.0, square_end_model(2.0, 0.0, 0.0, False)[0], square_end_model(2.0, 0.0, 0.0, True)[0]) assert abs(g.mpt_left - _geom().mpt_left) < 1e-12 and abs(g.track_m - 0.150) < 1e-12 # a perfect robot: nothing to correct if __name__ == "__main__": run_tests({n: f for n, f in globals().items() if n.startswith("test_drive_")})

15. Challenges

Each challenge has a worked solution, checked by running it, in solutions/roboprog1-5_exerciseN.txt. All three need the module of section 14 (the blocks of the chapter without an output) and the simulator, in one session.

Challenge 1: Measure a motor
A robot’s motors, which the software does not know, have a dead zone of 0.15 and reach 0.50 m/s at full duty. Build the rig for it from the simulator (RealMotors around a Simulator with max_speed_mps=0.50). (a) Sweep the duty from 0.10 to 1.00 with measure_speed_curves, and report the dead zone, the slope k and the full-duty speed from the fit, next to the truth. (b) Find the duty for 0.25 m/s from the measured curve and from the believed curve, and the speed the robot really reaches at each (settle first, then count ticks over a second). (c) A Motor with a slew limit of 3.0 command units per second: predict, then measure, the time to full command, and the time for the command that gives 0.25 m/s.
View solution
Challenge 2: The track width from spins
The robot’s true track width is 0.158 m; the software believes 0.150 m. (a) Spin it by odometry (TurnAngle) through one turn and through four turns, find the true angle by adding up the real heading changes, and estimate the track from each as believed track × odometry’s angle / true angle. (b) Put the four-turn value into the geometry (geom.scaled(track=...)), repeat the four-turn spin, and report how much the robot really turns for a command of four turns. (c) Say, from your numbers, why a longer spin is the better test.
View solution
Challenge 3: The watchdog timeout against the stopping distance
The robot drives at 0.30 m/s and must stop within 5 cm of the moment its controller goes quiet. The motors’ time constant is 0.08 s and the loop runs at 20 ms. (a) From distance ≤ v × timeout + v × τ, find the longest timeout that meets the budget. (b) Measure the distance rolled after silence for timeouts of 0.25, 0.10, 0.08, 0.06 and 0.04 s on the simulator. (c) Test the other side: a healthy controller with one pass that is 70 ms late. Which timeouts stop the robot falsely, and what would you change if the budget and the late pass must both be met?
View solution

Quick reference

  • Conventions: x forward, y left, counter-clockwise positive; metres, seconds, radians; heading wrapped to −π..+π but a total turn is not; counts positive when the wheel turns forward, fixed in the hardware class once.
  • Motor: duty = deadband + (1 − deadband) |cmd|, capped, zero below a cutoff; ramp time = delta / slew (a reversal is 2 / slew); a duty cap is not a speed cap; stop() is instant, ramp-free and safe in any state; a cap below the dead zone is refused.
  • Stopping: distance = speed × time constant; brake has the short constant, coast the long one; the simulator’s zero-duty lag is 0.08 s; measure all three on your robot.
  • Speed curve: speed = k (duty − deadband); fit from a sweep with points that moved only; duty for a speed = deadband + speed / k; a wrong map is a systematic error (+9.8 % here) that no loop which ignores speed will find.
  • Wheel speeds: left = v − omega track / 2, right = v + omega track / 2; when a wheel is over its limit, scale both by the same factor so the radius v / omega survives; clipping one wheel widens the circle.
  • Odometry: ds = (dL + dR) / 2, dtheta = (dR − dL) / track; arc x += R (sin(theta + dtheta) − sin theta), y −= R (cos(theta + dtheta) − cos theta); the midpoint differs from the arc by about ds dtheta2 / 24; at 20 ms both are far below one tick; keep the unwrapped total turn.
  • DriveBase pass: sense, run the move, check the guards, drive or cut; a bus error is a missing reading, not a crash; a move is a step function that never waits.
  • Stopping on a target: v = √(2 a r) with r = remaining − speed × lead; a creep speed to arrive, a settle threshold and a time limit that makes a stall visible; the lead is about the motor lag plus the filter delay and is measured (0.20 s here).
  • Calibration: straight line scale = tape / odometry; two squares, heading hold off: xccw = 2 L (phi + psi), xcw = −2 L (phi − psi) to first order; true track = track / (1 + 2 psi / π); delta = phi track / L; scales 2 / (2 + delta) and 2 (1 + delta) / (2 + delta); refine with the exact model when the errors are large; check with the squares, with the hold on.
  • Faults: what odometry can see, control corrects (heading hold, per-wheel curves); what it cannot (tyre size, track, slip) must be measured from outside.
  • Safety: cap, watchdog (distance ≤ v × timeout + v τ, and long enough not to trip on a late pass), encoder guard, stall guard; hold is not latched, fault is, and clears only by clear_fault() plus a new command; stop() never raises and says whether it worked; the board watchdog covers a dead loop.
  • On the robot (not run on hardware): wheels off the ground; direction; encoder sign; ticks per revolution by hand; brake and coast constants; speed curve; lead; calibration; guards; then raise the cap.

Next: Programming for Robotics 6, Control Loops in Code

This chapter gave the robot a body that obeys: a velocity command in, wheel speeds, duties and a pose out, moves that stop where they are told, calibration numbers measured against a tape, and guards that cut the motors when something is wrong. The controllers inside it are still the plainest ones: a square-root speed profile and a single proportional gain for the heading. Programming for Robotics 6 (planned; not yet written) is about the control loops themselves, in code: the proportional, integral and derivative controller of Robotics Fundamentals 7 as a small class with anti-windup and a derivative that does not amplify noise, a speed loop around the motors of this chapter that makes the measured wheel speed follow the command, a position and heading loop on the odometry, tuning against the simulator by measurement instead of by feel, and the test that tells a good tuning from a lucky one.