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.

Parallel tasks: runloop.run with several coroutines

runloop.run() accepts multiple coroutines and runs them concurrently - 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, 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

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."""
    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.
  • 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)

  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. KD typically ends up 3-10x KP.
  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:
        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:

  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.