Advanced: async & PID
The final lesson, two topics: understanding async concurrency (the foundation of SPIKE 3 Python) and writing competition-grade line following and straight driving with PID.
async: what actually happens
The hub has one CPU yet "simultaneously" runs motors, sensors and lights. async achieves this with cooperative multitasking: at every await, the program yields the CPU so the runloop can advance other tasks.
await something= "this takes time; wake me when it's done, let others run meanwhile".- Calling your own async function without
awaitdoes nothing visible (it only creates a coroutine object) - the most common bug:
straight(50) # ❌ nothing happens (straight is a user-defined async function)
await straight(50) # ✅ correctImportant distinction: LEGO's native API calls (motor.run_for_degrees etc.) DO start the action even without await - the program just doesn't wait for them to finish, like the "start motor" block. Only your own async def functions silently do nothing.
Concurrent tasks: runloop.run with several coroutines
runloop.run() accepts multiple coroutines and advances them concurrently (one CPU taking turns - "simultaneous" at the macro level) - the equivalent of multiple hat blocks:
import runloop, motor_pair
from hub import port, light_matrix
motor_pair.pair(motor_pair.PAIR_1, port.A, port.E)
async def drive():
await motor_pair.move_for_degrees(motor_pair.PAIR_1, 3600, 0, velocity=400)
async def blink():
while True:
light_matrix.show_image(light_matrix.IMAGE_HEART)
await runloop.sleep_ms(500)
light_matrix.clear()
await runloop.sleep_ms(500)
runloop.run(drive(), blink()) # drive and blink at onceEvery infinite loop needs an await
The sleep_ms in blink isn't just a delay - it's the yield point. A while True without any await hogs the CPU and freezes every other task.
Work while driving
The FLL time-saver: raise the arm on the way there. Note that runloop.run() may only be called once, at the top level - never inside a coroutine. To run things in parallel, write each action as its own coroutine and hand both to the top-level runloop.run():
async def raise_arm():
await motor.run_for_degrees(port.C, 120, 300)
async def drive_out():
await straight(60)
await turn(90)
# arm is in position on arrival
runloop.run(raise_arm(), drive_out()) # raise and drive at the same timePID control
P control reacts only to the current error, and in practice hits two problems: at small errors the output is small too, and friction plus actuator dead-band eat it (showing up as steady-state error); cranking Kp to compensate causes oscillation. PID adds two terms:
correction = Kp*error + Ki*accumulated_error + Kd*error_change_rate
P: how far off now I: historical debt D: trend braking- I (integral): sums the error every loop. Persistent small errors accumulate until they force a correction - eliminating steady-state error.
- D (derivative): this error minus the last one. When the error is shrinking fast, D outputs a counter-correction - easing off early, damping overshoot and oscillation.
- Strict discrete PID multiplies/divides the I and D terms by the sample period dt; here the loop interval is approximately fixed (5 ms of sleep plus compute/scheduling overhead), and the gains absorb the actual dt - that's why no explicit dt appears in the code. Change the sample interval and you must retune the gains.
A practical PID line follower
import runloop, motor_pair, color_sensor
from hub import port
motor_pair.pair(motor_pair.PAIR_1, port.A, port.E)
TARGET = 48 # line-edge middle value, calibrated on site
KP, KI, KD = 1.2, 0.002, 6.0
BASE = 300 # base speed, deg/s
async def follow_line_deg(degrees):
"""PID line follow for a given number of motor degrees.""" # teaching version: add timeout + stall stop for competition
import motor
motor.reset_relative_position(port.A, 0)
integral = 0
last_error = 0
while abs(motor.relative_position(port.A)) < degrees:
# left-edge follow (white-left, black-right): too white -> positive error -> steer right
error = color_sensor.reflection(port.F) - TARGET
integral += error
integral = max(-1000, min(1000, integral)) # anti-windup clamp
derivative = error - last_error
last_error = error
correction = KP * error + KI * integral + KD * derivative
motor_pair.move_tank(motor_pair.PAIR_1,
int(BASE + correction),
int(BASE - correction))
await runloop.sleep_ms(5) # yield; 5 ms loop interval
motor_pair.stop(motor_pair.PAIR_1)
async def main():
await follow_line_deg(2000)
runloop.run(main())Implementation notes:
move_tankdrives the wheel-speed difference directly - more linear than the steering parameter.- Keep the anti-windup clamp on the integral - strongly recommended, or the big error at startup accumulates into a massive overshoot.
- The loop runs every 5 ms; actual readings update at the color sensor's 100 Hz limit, and the loop itself has overhead. Still far denser than Word Blocks loops - a key reason Python line following is steadier.
- This is teaching code, kept lean: a real competition deployment should also add a timeout and stall detection (stop if motor velocity sits at 0), so a jammed robot doesn't grind in place.
Tuning order (recommended flow)
KI = KD = 0, tune KP: increase until slight oscillation, then back off to 60-70%.- Add KD: increase from 0 until oscillation disappears and curves are crisp. With this article's sample interval and units KD often lands at 3-10x KP - an empirical value for this setup, not a universal rule.
- Most line followers don't need KI (no steady-state error to fight). Only if the robot consistently rides off-center, add a tiny amount (0.001 order).
- After raising BASE speed, return to step 1 and touch up.
PID gyro straight
The same PID with yaw as the error - top-tier straight driving:
import runloop, motor_pair
from hub import port, motion_sensor
async def straight_pid(degrees, velocity=500, kp=4.0, kd=8.0):
import motor
motion_sensor.reset_yaw(0)
motor.reset_relative_position(port.A, 0)
last_error = 0
while abs(motor.relative_position(port.A)) < degrees:
# App 3 raw yaw is left-positive, which numerically IS the error "0 - right-positive yaw"
error = motion_sensor.tilt_angles()[0] / 10
d = error - last_error
last_error = error
correction = kp * error + kd * d
motor_pair.move_tank(motor_pair.PAIR_1,
int(velocity + correction),
int(velocity - correction))
await runloop.sleep_ms(5)
motor_pair.stop(motor_pair.PAIR_1)You've reached the top
The tech stack you now hold - encoder distance + gyro heading + PID control + async concurrency - is the same one world-class FLL teams run. What remains is mechanical design and practice hours.
FAQ
Nothing happens when I call my own async function - why?
Calling a user-defined async function without await only creates a coroutine object; it never runs - the most common bug. Note LEGO native API calls are the opposite: they start even without await.
My PID line follower keeps oscillating - what now?
Back KP off to 60-70% of where oscillation started, then raise KD from 0 until overshoot is damped. Retune all gains whenever you change speed or the sample interval.
Do I actually need the integral term KI?
Usually no - line following has no persistent steady-state error to cancel. Add a tiny amount (order 0.001) only if the robot consistently rides off-center, and always keep the anti-windup clamp.
Final exercises:
- Use the tuning order to get your PID line follower stable at 60%+ base speed.
- Collect
follow_line_deg,straight_pidandturninto one library file the whole team shares - that's your team's playbook.