KevsRobots Learning Platform
84% Percent Complete
By Kevin McAleer, 9 Minutes
Page last updated June 13, 2026

This is the payoff lesson. Weβve trained a policy, understood its limitations, and built the sensor infrastructure. Now we put it all together: the Q-table travels from the laptop to the Pico as a JSON file, and a MicroPython script runs the greedy policy in a continuous loop, reading sensors and driving motors in real time.
Letβs go.
Before deploying, we need to retrain using the simplified state (heading + distance only, no grid position). This is necessary because the real robot canβt track (row, col) without encoders.
In your train.py, update _get_state():
def _get_state(self):
"""Simplified state: heading + sensor only (no grid position)."""
sensor = self._read_sensor()
return (self.heading, sensor) # 4 headings Γ 3 bands = 12 states
The Q-table now has at most 12 rows β small enough to fit comfortably on the Pico. Retrain and save:
python3 train.py
# Creates q_table.json in the current directory
Verify the saved file looks reasonable:
{
"('east', 'far')": {"forward": 3.21, "turn_left": 0.15, ...},
"('east', 'near')": {"forward": -4.50, "turn_left": 1.21, ...},
"('north', 'far')": {"forward": 2.80, "turn_left": 0.10, ...},
...
}
Using Thonny (or mpremote):
q_table.json from your laptopq_table.jsonmain.py on the Pico with the code belowAlternatively, with mpremote:
mpremote connect auto cp q_table.json :q_table.json
Save this as main.py on the Pico. It is the complete, standalone deployment β no training, no exploration, just the greedy policy:
"""
BurgerBot RL Deployment
========================
Loads a trained Q-table from q_table.json and runs the greedy
policy on a Raspberry Pi Pico with:
- L298N H-bridge motor driver
- HC-SR04 ultrasonic sensor (3.3V compatible or voltage-divided)
Hardware pin assignments match the MicroPython Robotics Projects course:
Motor driver ENA -> GP0 (PWM speed control, left motor)
Motor driver IN1 -> GP1 (left motor direction A)
Motor driver IN2 -> GP2 (left motor direction B)
Motor driver IN3 -> GP3 (right motor direction A)
Motor driver IN4 -> GP4 (right motor direction B)
Motor driver ENB -> GP5 (PWM speed control, right motor)
HC-SR04 TRIG -> GP8
HC-SR04 ECHO -> GP9
"""
import json
import time
from machine import Pin, PWM, time_pulse_us
# ---------------------------------------------------------------------------
# Pin configuration
# ---------------------------------------------------------------------------
# Left motor
ENA = PWM(Pin(0)); ENA.freq(1000)
IN1 = Pin(1, Pin.OUT)
IN2 = Pin(2, Pin.OUT)
# Right motor
ENB = PWM(Pin(5)); ENB.freq(1000)
IN3 = Pin(3, Pin.OUT)
IN4 = Pin(4, Pin.OUT)
# Ultrasonic sensor
TRIG = Pin(8, Pin.OUT)
ECHO = Pin(9, Pin.IN)
# ---------------------------------------------------------------------------
# Motor constants
# ---------------------------------------------------------------------------
# Duty cycle for "normal" forward speed (0β65535 on Pico PWM)
NORMAL_SPEED = 40000 # ~61% duty cycle
TURN_SPEED = 35000 # slightly slower for turns
# Turn duration in milliseconds β calibrate for your robot
# A 90Β° turn at TURN_SPEED on a smooth floor β 400β600 ms
TURN_DURATION_MS = 500
# ---------------------------------------------------------------------------
# Motor control
# ---------------------------------------------------------------------------
def set_motors(left_duty, left_fwd, right_duty, right_fwd):
"""
Set both motors simultaneously.
left_duty (int) : PWM duty cycle for left motor (0β65535)
left_fwd (bool) : True = forward, False = reverse
right_duty (int) : PWM duty cycle for right motor (0β65535)
right_fwd (bool) : True = forward, False = reverse
"""
# Left motor
ENA.duty_u16(left_duty)
IN1.value(1 if left_fwd else 0)
IN2.value(0 if left_fwd else 1)
# Right motor
ENB.duty_u16(right_duty)
IN3.value(1 if right_fwd else 0)
IN4.value(0 if right_fwd else 1)
def motor_stop():
ENA.duty_u16(0)
ENB.duty_u16(0)
IN1.value(0); IN2.value(0)
IN3.value(0); IN4.value(0)
def motor_forward():
set_motors(NORMAL_SPEED, True, NORMAL_SPEED, True)
def motor_turn_left():
"""Spin in place: left motor backward, right motor forward."""
set_motors(TURN_SPEED, False, TURN_SPEED, True)
time.sleep_ms(TURN_DURATION_MS)
motor_stop()
time.sleep_ms(50) # brief pause before next command
def motor_turn_right():
"""Spin in place: left motor forward, right motor backward."""
set_motors(TURN_SPEED, True, TURN_SPEED, False)
time.sleep_ms(TURN_DURATION_MS)
motor_stop()
time.sleep_ms(50)
# ---------------------------------------------------------------------------
# Ultrasonic sensor
# ---------------------------------------------------------------------------
def get_distance_cm():
"""Return distance in cm, or -1.0 on timeout."""
TRIG.low()
time.sleep_us(2)
TRIG.high()
time.sleep_us(10)
TRIG.low()
duration = time_pulse_us(ECHO, 1, 30000)
if duration < 0:
return -1.0
return duration / 58.0
def get_distance_filtered():
"""Median of three readings for noise rejection."""
readings = []
for _ in range(3):
d = get_distance_cm()
if d > 0:
readings.append(d)
time.sleep_ms(20)
if not readings:
return -1.0
readings.sort()
return readings[len(readings) // 2]
def discretize_distance(distance_cm):
"""
Map raw distance to the state label used during training.
Thresholds must match train.py.
"""
if distance_cm < 0:
return "near" # conservative: treat failure as obstacle
if distance_cm < 15:
return "near"
elif distance_cm < 40:
return "medium"
else:
return "far"
# ---------------------------------------------------------------------------
# Heading tracking
# ---------------------------------------------------------------------------
HEADINGS = ["north", "east", "south", "west"]
_heading_idx = 1 # start facing east
def get_heading():
return HEADINGS[_heading_idx]
def update_heading(direction):
global _heading_idx
if direction == "left":
_heading_idx = (_heading_idx - 1) % 4
else:
_heading_idx = (_heading_idx + 1) % 4
# ---------------------------------------------------------------------------
# Q-table loading
# ---------------------------------------------------------------------------
def load_q_table(filename="q_table.json"):
"""
Load Q-table from JSON. Keys are stored as strings; convert back
to tuples using eval() β safe here because we generated the file.
"""
with open(filename, "r") as f:
raw = json.load(f)
q_table = {}
for k, v in raw.items():
# Key looks like "('east', 'far')" β evaluate it as a Python literal
# MicroPython doesn't have ast.literal_eval, but the format is safe
# (we generated the file ourselves from trusted training code)
key = eval(k) # noqa: S307 β trusted source only
q_table[key] = v
return q_table
# ---------------------------------------------------------------------------
# Policy execution
# ---------------------------------------------------------------------------
def choose_action_greedy(q_table, state):
"""
Return the action with the highest Q-value.
Falls back to 'forward' if state not found in table.
"""
if state not in q_table:
print(f"Unknown state {state} β defaulting to 'forward'")
return "forward"
action_values = q_table[state]
return max(action_values, key=lambda a: action_values[a])
def execute_action(action):
"""Execute an action on the real motors and update heading."""
if action == "forward":
motor_forward()
elif action == "turn_left":
motor_stop()
motor_turn_left()
update_heading("left")
elif action == "turn_right":
motor_stop()
motor_turn_right()
update_heading("right")
elif action == "stop":
motor_stop()
time.sleep_ms(200)
def get_state():
"""Compose the current state tuple for Q-table lookup."""
distance_cm = get_distance_filtered()
band = discretize_distance(distance_cm)
heading = get_heading()
return (heading, band)
# ---------------------------------------------------------------------------
# Safety override
# ---------------------------------------------------------------------------
EMERGENCY_STOP_CM = 8 # stop unconditionally if closer than this
def safety_check(distance_cm):
"""
Return True if the robot is too close to stop, False otherwise.
Call before executing any forward action.
"""
return distance_cm < EMERGENCY_STOP_CM and distance_cm > 0
# ---------------------------------------------------------------------------
# Main control loop
# ---------------------------------------------------------------------------
def run():
"""
Load the Q-table and run the greedy policy continuously.
Press Ctrl+C to stop.
"""
print("Loading Q-table...")
try:
q_table = load_q_table("q_table.json")
except OSError:
print("ERROR: q_table.json not found on Pico! Copy it first.")
return
print(f"Q-table loaded: {len(q_table)} states")
print("Starting policy execution. Ctrl+C to stop.")
time.sleep_ms(1000) # brief pause before starting
step = 0
try:
while True:
# 1. Read sensor
distance_cm = get_distance_filtered()
# 2. Safety override
if safety_check(distance_cm):
print(f"SAFETY STOP: {distance_cm:.1f} cm")
motor_stop()
time.sleep_ms(500)
# Force a turn to escape
motor_turn_right()
update_heading("right")
continue
# 3. Get state and look up best action
state = get_state()
action = choose_action_greedy(q_table, state)
# 4. Execute action
execute_action(action)
# 5. Log to console
print(f"Step {step:4d} | dist={distance_cm:5.1f}cm | "
f"state={state} | action={action}")
step += 1
# 6. Brief pause between control cycles
time.sleep_ms(100)
except KeyboardInterrupt:
motor_stop()
print("\nStopped.")
# ---------------------------------------------------------------------------
# Entry point
# ---------------------------------------------------------------------------
run()
q_table.json and main.py to the Pico using Thonnymain.py is the startup file)The TURN_DURATION_MS = 500 constant controls how long the robot spins to execute a 90Β° turn. This value varies by robot:
# To calibrate: test turns manually
# Start with 500 ms. If the robot turns less than 90Β°, increase.
# If it overshoots, decrease. Aim for 85β95Β° β close enough for the policy.
# Quick calibration test (run in Thonny REPL):
motor_turn_right() # should turn approximately 90Β° clockwise
Run the robot in a large, clear space first. Observe which actions it takes most often. Does the distribution of actions make sense given the Q-table?
Place a single obstacle (a box) directly in front of the robot at 30 cm. Observe how it detects and avoids it. Move the obstacle to different positions and watch the policy adapt.
Modify run() to keep a count of successful forward steps and turns. After 200 steps, print the ratio. A good policy should show more than 50% forward steps (the robot is making progress, not just spinning).
βThe robot just spins in circles.β
The Q-table may not have good coverage of the heading + βfarβ states. Retrain with more episodes (800+) or check that the state used in training matches the state in get_state() exactly.
βIt hits an obstacle before the safety fallback triggers.β
Increase EMERGENCY_STOP_CM from 8 to 12 cm, and shorten the TURN_DURATION_MS so turns complete faster.
βeval() raises an error when loading the Q-table.β
The key string format from save_q_table() on your laptop must exactly match what eval() expects. Check that strings inside the tuple use single quotes (Pythonβs default repr() uses them). Alternatively, use the ast.literal_eval() equivalent on MicroPython: the ujson module parses JSON fine but canβt handle tuple-as-string keys directly β this is why we use eval() in the deployment code.
βThe motors donβt move at all.β Check your motor driver wiring and that the PWM duty cycle isnβt 0 (the Pico PWM duty_u16 range is 0β65535, not 0β100). Verify ENA and ENB are connected and receiving PWM signal with a voltmeter or LED test.
Congratulations β your robot is now learning-enabled! Next up: a brief look at where tabular Q-learning runs out of road, and what Deep RL does differently.
You can use the arrows β β on your keyboard to navigate between lessons.
Comments