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.
Parallel tasks: runloop.run with several coroutines
runloop.run() accepts multiple coroutines and runs them concurrently - 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, with two inherent flaws: small errors don't move it (steady-state error), and cranking Kp 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.
Competition-grade 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."""
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.- The integral clamp (anti-windup) is mandatory, 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.
Tuning order (always this order)
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. KD typically ends up 3-10x KP.
- 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:
error = -motion_sensor.tilt_angles()[0] / 10 # target yaw = 0
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 parallelism - is the same one world-class FLL teams run. What remains is mechanical design and practice hours.
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.