Skip to content

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 await does nothing visible (it only creates a coroutine object) - the most common bug:
python
straight(50)          # ❌ nothing happens (straight is a user-defined async function)
await straight(50)    # ✅ correct

Important 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:

python
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 once

Every 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():

python
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 time

PID 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:

wordblocks
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

python
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_tank drives 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.
  1. KI = KD = 0, tune KP: increase until slight oscillation, then back off to 60-70%.
  2. 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.
  3. 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).
  4. 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:

python
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:

  1. Use the tuning order to get your PID line follower stable at 60%+ base speed.
  2. Collect follow_line_deg, straight_pid and turn into one library file the whole team shares - that's your team's playbook.
© 2026 FLL Knowledge Base · All rights reserved