🌐 Level 6 – Wi-Fi Robotics

Project 6.12: "Integration N1 to N6"

 

What you’ll learn

  • ✅ Line sensing: Read a 3–5 IR reflectance array, normalize values, and detect the line position.
  • ✅ Error and PID control: Compute lateral error and use P–I–D terms to steer smoothly.
  • ✅ Motor mixing: Convert steering output into left/right wheel speeds with safe limits and ramps.
  • ✅ Auto‑tune basics: Run a step test to estimate gains and prevent oscillations.
  • ✅ Lap timing + logging: Time laps, log parameters and performance, and replay settings.

Blocks glossary (used in this project)

  • IR reflectance inputs: Analog reads from sensor array pins (e.g., 32/33/34/35/36).
  • Normalization: Map raw ADC 0–4095 to 0.0–1.0 and clamp.
  • Line position estimate: Weighted center index or threshold hit.
  • PID controller: (u = K_p e + K_i \int e , dt + K_d \frac{de}{dt}) for steering output.
  • Motor mixing: Base speed ± steering with min/max clamps and acceleration ramp.
  • Serial println: Short tags: “SENS:…”, “ERR:…”, “PID:…”, “MIX:…”, “LAP:…”, “LOG:…”, “SAFE:…”.

What you need

PartHow many?Pin connection / Notes
D1 R32 (ESP32)1USB cable (30 cm)
IR reflectance array (3–5 sensors)1 sete.g., A0→Pin 32, A1→33, A2→34, A3→35, A4→36
L298N + TT motors1 setLeft IN1→18, IN2→19; Right IN3→5, IN4→23
LED + buzzer (optional)1 eachLED → Pin 13, Buzzer → Pin 23
Track line (black tape on white)1High contrast for easier sensing

Notes

  • Keep sensor array 3–5 mm above the surface; align straight across the robot’s front.
  • Share ground between sensors, R32, and motor driver.
  • If using 5 sensors, adjust the arrays and weights accordingly.

Before you start

  • Array mounted and wired to ADC pins
  • Motors wired; wheels spin freely
  • Serial monitor open and shows:
print("Ready!")  # Confirm serial is working so you can see messages

Microprojects 1–5

Microproject 6.12.1 – Reading and normalizing the IR array

Goal: Read 3–5 sensors, normalize to 0.0–1.0, and print a clean snapshot.
Blocks used:

  • ADC read: 12‑bit.
  • Normalization: value/4095, clamp 0–1.

MicroPython code:

import machine  # Import machine for ADC pins
import time  # Import time for small delays

# ADC sensors: adjust for 3–5 channels as available
pins = [32, 33, 34, 35, 36]  # Define ADC-capable pins for the array
adcs = []  # Create a list to store ADC objects
for p in pins:  # Iterate over each pin number
    a = machine.ADC(machine.Pin(p))  # Create ADC object on pin p
    a.atten(machine.ADC.ATTN_11DB)  # Set 11 dB attenuation for wider range
    a.width(machine.ADC.WIDTH_12BIT)  # Set 12-bit resolution (0–4095)
    adcs.append(a)  # Append ADC object to list
print("SENS:READY", pins)  # Print sensor pin setup

def read_norm():  # Define function to read and normalize sensors
    vals = []  # Create list for normalized values
    for a in adcs:  # Iterate over each ADC object
        raw = a.read()  # Read raw 12-bit value
        norm = max(0.0, min(1.0, raw / 4095.0))  # Normalize and clamp to 0–1
        vals.append(round(norm, 3))  # Append rounded normalized value
    print("SENS:NORM", vals)  # Print normalized array snapshot
    return vals  # Return normalized values list

snap = read_norm()  # Take one normalized reading
time.sleep(0.1)  # Short delay for readability

Reflection: Normalized values make the array easy to read and compare between sensors.
Challenge:

  • Easy: Print raw values and normalized together.
  • Harder: Add per‑sensor calibration offsets and scales.

Microproject 6.12.2 – Estimating line position and error

Goal: Compute a weighted center index and error around the middle sensor.
Blocks used:

  • Weights: Use indices −2…+2 or −1…+1.
  • Threshold: Consider dark line as “high” or “low” depending on sensor type.

MicroPython code:

# Choose interpretation: if dark increases reading, set DARK_HIGH=True
DARK_HIGH = True  # Define whether dark line yields higher normalized numbers
weights = [-2, -1, 0, 1, 2]  # Define weights for a 5-sensor array (adjust for 3-sensor case)
print("ERR:WEIGHTS", weights)  # Print weights

def line_pos(vals):  # Define function to estimate line position
    # If dark is low, invert values for weighting
    use = vals if DARK_HIGH else [1.0 - v for v in vals]  # Build weighted values list
    total = sum(use) + 1e-6  # Compute sum with small epsilon to avoid zero
    pos = 0.0  # Initialize position accumulator
    for w, v in zip(weights, use):  # Iterate over weight and value pairs
        pos += w * v  # Accumulate weighted position
    center = pos / total  # Normalize by total
    print("ERR:POS", round(center, 3))  # Print estimated position (negative=left)
    return center  # Return center position

vals = read_norm()  # Read normalized sensor array
center = line_pos(vals)  # Compute line position estimate
err = -center  # Define control error (positive means steer right)
print("ERR:VAL", round(err, 3))  # Print control error

Reflection: A simple weighted center turns many sensors into one clean steering signal.
Challenge:

  • Easy: Switch DARK_HIGH to match your array behavior.
  • Harder: If total is below a threshold, print “ERR:LOST_LINE”.

Microproject 6.12.3 – Building a PID controller

Goal: Implement P–I–D for steering with clamps and integral windup protection.
Blocks used:

  • P/I/D terms: Kp, Ki, Kd; store integral and last error.
  • Clamps: Limit u and integral range.

MicroPython code:

import time  # Import time for dt

Kp = 1.4  # Set proportional gain
Ki = 0.0  # Set integral gain (start at 0 to tune later)
Kd = 0.08  # Set derivative gain
i_sum = 0.0  # Initialize integral sum term
last_err = 0.0  # Initialize last error for derivative
print("PID:GAINS", Kp, Ki, Kd)  # Print initial gains

def pid_step(err, dt):  # Define PID step function
    global i_sum, last_err  # Use global integral and last error
    p = Kp * err  # Compute proportional term
    i_sum += err * dt  # Accumulate integral with dt
    i_sum = max(-1.0, min(1.0, i_sum))  # Clamp integral to avoid windup
    i = Ki * i_sum  # Compute integral contribution
    d = Kd * ((err - last_err) / dt if dt > 0 else 0.0)  # Compute derivative term
    u = p + i + d  # Sum PID terms for control output
    u = max(-1.0, min(1.0, u))  # Clamp output to −1..+1
    last_err = err  # Update last error for next step
    print("PID:STEP p", round(p, 3), "i", round(i, 3), "d", round(d, 3), "u", round(u, 3))  # Print terms
    return u  # Return control output

# Demo one step with fake dt
dt = 0.05  # Define sample time in seconds
u = pid_step(err, dt)  # Compute control output

Reflection: Printing each PID term helps students see what the controller is “thinking.”
Challenge:

  • Easy: Enable Ki (e.g., 0.02) and observe integral behavior.
  • Harder: Add an “integral freeze” when line is lost.

Microproject 6.12.4 – Motor mixing and ramping

Goal: Convert steering output to left/right speeds with limits and ramps.
Blocks used:

  • Base speed: Set forward speed fraction.
  • Ramp: Limit change per step to reduce jerks.

MicroPython code:

import machine  # Import machine for motor pins
import time  # Import time for pulse durations

# Motor pins for L298N driver
L_IN1 = machine.Pin(18, machine.Pin.OUT)  # Left IN1
L_IN2 = machine.Pin(19, machine.Pin.OUT)  # Left IN2
R_IN3 = machine.Pin(5, machine.Pin.OUT)   # Right IN3
R_IN4 = machine.Pin(23, machine.Pin.OUT)  # Right IN4
print("MOTORS:READY 18/19 5/23")  # Print motor pins ready

base = 0.6  # Set base forward speed fraction (0..1)
last_l = 0.0  # Initialize last left speed fraction
last_r = 0.0  # Initialize last right speed fraction
ramp = 0.2  # Set max change per step (fraction)
pulse_ms = 120  # Set pulse duration per step in milliseconds
print("MIX:BASE", base, "RAMP", ramp)  # Print mixing config

def clamp(x):  # Define clamp helper for speed fractions
    return max(0.0, min(1.0, x))  # Clamp to 0..1

def apply_mix(u):  # Define function to apply steering mix
    global last_l, last_r  # Use global last speeds
    l_target = clamp(base + u)  # Compute left target fraction
    r_target = clamp(base - u)  # Compute right target fraction
    dl = max(-ramp, min(ramp, l_target - last_l))  # Limit left change by ramp
    dr = max(-ramp, min(ramp, r_target - last_r))  # Limit right change by ramp
    l = clamp(last_l + dl)  # Compute new left fraction
    r = clamp(last_r + dr)  # Compute new right fraction
    print("MIX:LR", round(l, 2), round(r, 2))  # Print left/right fractions
    last_l, last_r = l, r  # Update last speeds
    drive_pulse(l, r, pulse_ms)  # Drive motors for one pulse

def drive_pulse(lf, rf, ms):  # Define motor driving for one pulse
    # Left motor direction: forward only for simplicity
    L_IN1.value(1 if lf > 0 else 0)  # Set left forward HIGH if lf > 0
    L_IN2.value(0)  # Keep left backward LOW
    # Right motor direction: forward only
    R_IN3.value(1 if rf > 0 else 0)  # Set right forward HIGH if rf > 0
    R_IN4.value(0)  # Keep right backward LOW
    print("MOVE:PULSE", ms, "LF", round(lf, 2), "RF", round(rf, 2))  # Print pulse info
    time.sleep(ms / 1000.0)  # Run motors for ms/1000 seconds
    # Stop after pulse
    L_IN1.value(0)  # Set left forward LOW
    R_IN3.value(0)  # Set right forward LOW
    print("MOVE:STOP")  # Print stop

apply_mix(u)  # Apply one mix from PID output

Reflection: Ramping avoids sudden jerks—robots feel smooth and controlled.
Challenge:

  • Easy: Add reverse direction when rf or lf falls below 0.1 for tight turns.
  • Harder: Implement a soft brake by pulsing the opposite direction briefly.

Microproject 6.12.5 – Step test auto‑tune basics

Goal: Run a step input, record response, and suggest Kp/Kd ranges.
Blocks used:

  • Step: Drive a fixed u for N pulses.
  • Metrics: Max overshoot, settling hint.

MicroPython code:

import time  # Import time for timing

resp = []  # Create list to store (t, err, u) triplets
print("TUNE:START")  # Print start of tuning

def step_run(u_fixed=0.3, steps=10):  # Define step test runner
    global last_err  # Use last_err for derivative continuity
    dt = 0.06  # Define sample time per step
    for i in range(steps):  # Iterate step count
        vals = read_norm()  # Read sensors
        center = line_pos(vals)  # Compute line center
        err = -center  # Compute error
        u = pid_step(err, dt) if i > 0 else u_fixed  # Use fixed on first, then PID
        apply_mix(u)  # Apply mixing to motors
        resp.append((i * dt, err, u))  # Append response tuple
        time.sleep(0.02)  # Short gap for logging

step_run(0.3, 12)  # Run a short step test

# Simple suggestion: if overshoot visible, reduce Kp or increase Kd
overs = max(abs(e) for _, e, _ in resp)  # Compute max absolute error
print("TUNE:OVERSHOOT", round(overs, 3))  # Print overshoot
if overs > 1.5:  # If overshoot too large
    print("TUNE:SUGGEST Kp ->", round(Kp * 0.8, 3), "Kd ->", round(Kd * 1.2, 3))  # Print suggestion
else:  # If overshoot acceptable
    print("TUNE:SUGGEST OK")  # Print OK suggestion

Reflection: Even a tiny step test teaches students how gains change behavior.
Challenge:

  • Easy: Log resp to a file for later plotting.
  • Harder: Compute a settling time and print “TUNE:SETTLED_MS”.

Main project – PID line follower with auto‑tune and logging

Blocks steps (with glossary)

  • Sensor read + normalize: Clean array snapshot on each cycle.
  • Line position + error: Weighted center with LOST_LINE guard.
  • PID controller: P–I–D with windup clamp and printed terms.
  • Motor mixing + ramp: Base speed ± u, clamped, pulsed.
  • Auto‑tune: Short step test to refine Kp/Kd.
  • Lap timing + logging: Log laps, gains, and summaries.

MicroPython code (mirroring blocks)

# Project 6.12 – PID Line Follower (Sensors + PID + Mix + Auto-tune + Logging)

import machine  # Import machine for ADC and motor pins
import time  # Import time for dt, laps, and logging

# ===== Sensors (ADC array) =====
pins = [32, 33, 34, 35, 36]  # Define sensor pins
adcs = []  # Create ADC objects list
for p in pins:  # Iterate pins
    a = machine.ADC(machine.Pin(p))  # Create ADC on pin
    a.atten(machine.ADC.ATTN_11DB)  # Set attenuation
    a.width(machine.ADC.WIDTH_12BIT)  # Set resolution
    adcs.append(a)  # Append to list
print("INIT:SENS", pins)  # Print sensor init

DARK_HIGH = True  # Define dark line interpretation
weights = [-2, -1, 0, 1, 2]  # Define weights for 5 sensors
print("INIT:WEIGHTS", weights)  # Print weights

def read_norm():  # Read normalized sensor values
    vals = []  # Create normalized list
    for a in adcs:  # Iterate sensors
        raw = a.read()  # Read raw
        norm = max(0.0, min(1.0, raw / 4095.0))  # Normalize 0..1
        vals.append(norm)  # Append value
    print("SENS:NORM", [round(v, 3) for v in vals])  # Print normalized
    return vals  # Return list

def line_pos(vals):  # Estimate line position
    use = vals if DARK_HIGH else [1.0 - v for v in vals]  # Choose values
    total = sum(use) + 1e-6  # Sum with epsilon
    pos = 0.0  # Initialize position
    for w, v in zip(weights, use):  # Iterate pairs
        pos += w * v  # Accumulate weighted
    center = pos / total  # Normalize by total
    print("ERR:POS", round(center, 3))  # Print position
    return center  # Return position

# ===== PID controller =====
Kp = 1.4  # Set proportional gain
Ki = 0.0  # Set integral gain
Kd = 0.08  # Set derivative gain
i_sum = 0.0  # Initialize integral storage
last_err = 0.0  # Initialize last error
print("INIT:PID", Kp, Ki, Kd)  # Print gains

def pid_step(err, dt):  # Compute PID output
    global i_sum, last_err  # Use globals
    p = Kp * err  # Proportional
    i_sum += err * dt  # Integrate
    i_sum = max(-1.0, min(1.0, i_sum))  # Clamp integral
    i = Ki * i_sum  # Integral term
    d = Kd * ((err - last_err) / dt if dt > 0 else 0.0)  # Derivative
    u = p + i + d  # Sum
    u = max(-1.0, min(1.0, u))  # Clamp output
    last_err = err  # Update last error
    print("PID:STEP p", round(p, 3), "i", round(i, 3), "d", round(d, 3), "u", round(u, 3))  # Print terms
    return u  # Return control

# ===== Motors and mixing =====
L_IN1 = machine.Pin(18, machine.Pin.OUT)  # Left IN1
L_IN2 = machine.Pin(19, machine.Pin.OUT)  # Left IN2
R_IN3 = machine.Pin(5, machine.Pin.OUT)   # Right IN3
R_IN4 = machine.Pin(23, machine.Pin.OUT)  # Right IN4
print("INIT:MOTORS 18/19 5/23")  # Print motor pins

base = 0.6  # Base forward fraction
last_l = 0.0  # Last left fraction
last_r = 0.0  # Last right fraction
ramp = 0.2  # Max change per step
pulse_ms = 120  # Pulse duration per step
print("INIT:MIX base", base, "ramp", ramp, "pulse", pulse_ms)  # Print mix init

def clamp(x):  # Clamp fraction
    return max(0.0, min(1.0, x))  # Clamp 0..1

def drive_pulse(lf, rf, ms):  # Drive motors for a pulse
    L_IN1.value(1 if lf > 0 else 0)  # Left forward HIGH if lf>0
    L_IN2.value(0)  # Left backward LOW
    R_IN3.value(1 if rf > 0 else 0)  # Right forward HIGH if rf>0
    R_IN4.value(0)  # Right backward LOW
    print("MOVE:PULSE", ms, "LF", round(lf, 2), "RF", round(rf, 2))  # Print pulse info
    time.sleep(ms / 1000.0)  # Run motors
    L_IN1.value(0)  # Left forward LOW
    R_IN3.value(0)  # Right forward LOW
    print("MOVE:STOP")  # Print stop

def apply_mix(u):  # Apply steering mix
    global last_l, last_r  # Use last fractions
    l_target = clamp(base + u)  # Left target
    r_target = clamp(base - u)  # Right target
    dl = max(-ramp, min(ramp, l_target - last_l))  # Left ramp
    dr = max(-ramp, min(ramp, r_target - last_r))  # Right ramp
    l = clamp(last_l + dl)  # New left
    r = clamp(last_r + dr)  # New right
    print("MIX:LR", round(l, 2), round(r, 2))  # Print fractions
    last_l, last_r = l, r  # Update last
    drive_pulse(l, r, pulse_ms)  # Drive motors

# ===== Logging + laps =====
log_name = "pid_lap.txt"  # Log filename
laps = 0  # Lap counter
t_lap_start = time.ticks_ms()  # Lap start time
print("INIT:LOG", log_name)  # Print log file

def log_write(line):  # Write log line
    try:  # Try open
        with open(log_name, "a") as f:  # Open append
            f.write(line + "\n")  # Write with newline
        print("LOG:WRITE", line)  # Print written
    except OSError:  # On error
        print("LOG:ERR")  # Print error

def mark_lap():  # Mark a lap
    global laps, t_lap_start  # Use globals
    now = time.ticks_ms()  # Current ms
    lap_ms = time.ticks_diff(now, t_lap_start)  # Lap duration
    laps += 1  # Increment laps
    t_lap_start = now  # Reset start
    print("LAP:MS", lap_ms, "LAPS", laps)  # Print lap
    log_write("LAP ms=" + str(lap_ms) + " laps=" + str(laps))  # Log lap

# ===== Auto-tune (short) =====
def step_tune(steps=10):  # Run a short tune
    dt = 0.06  # Sample time
    for i in range(steps):  # Iterate steps
        vals = read_norm()  # Read sensors
        center = line_pos(vals)  # Compute center
        err = -center  # Compute error
        u = pid_step(err, dt)  # Compute PID
        apply_mix(u)  # Apply mix
        time.sleep(0.02)  # Short gap
    print("TUNE:DONE")  # Print done

# ===== Main follow loop =====
print("RUN:PID FOLLOW")  # Announce start
log_write("START Kp=" + str(Kp) + " Ki=" + str(Ki) + " Kd=" + str(Kd))  # Log gains

for _ in range(20):  # Optional pre-run tune pulses
    step_tune(steps=4)  # Run small tune
    time.sleep(0.05)  # Short pause

# Main control loop
while True:  # Continuous line follow
    start = time.ticks_ms()  # Timestamp before cycle
    vals = read_norm()  # Read sensors
    center = line_pos(vals)  # Compute center
    # Detect lost line by total reflectance magnitude
    total = sum(vals)  # Sum normalized values
    if total < 0.2:  # If too low (likely off track)
        print("SAFE:LOST_LINE")  # Print lost line
        # Reduce base and slow for recovery
        base = max(0.4, base - 0.1)  # Lower base speed
        apply_mix(0.0)  # Drive straight slow pulse
        time.sleep(0.1)  # Pause
        continue  # Skip PID step this cycle
    err = -center  # Compute error
    dt = max(0.02, (time.ticks_ms() - start) / 1000.0)  # Compute dt seconds
    u = pid_step(err, dt)  # Compute PID output
    apply_mix(u)  # Apply motor mix
    # Lap heuristic: center near zero for sustained period
    if abs(center) < 0.2 and total > 0.6:  # If near middle, strong signal
        mark_lap()  # Mark a lap
    time.sleep(0.02)  # Small pacing delay

External explanation

  • What it teaches: You built a complete line follower: sensor normalization, weighted line position, PID steering, smooth motor mixing with ramps, a quick auto‑tune step test, and lap timing with logs.
  • Why it works: The weighted center compresses many sensors into one error; PID stabilizes steering; ramps keep motion gentle; a simple tune loop reveals how gains affect behavior; logging makes progress visible.
  • Key concept: “Sense → estimate → control → move → measure.”

Story time

Tape on the floor becomes a path. Your robot reads the stripes, nudges left and right—never jerky, just confident. A lap completes, the printout smiles: LAP:MS 4382. You tweak Kp by a hair and watch it glide even cleaner.


Debugging (2)

Debugging 6.12.1 – Robot oscillates left/right

Problem: It wiggles and can’t settle on the line.
Clues: PID:STEP shows large p and d flipping signs quickly.
Broken code:

Kp = 2.8  # Too high
Kd = 0.0  # No damping

Fixed code:

Kp = 1.4  # Moderate proportional
Kd = 0.08  # Add derivative damping
print("DEBUG:GAINS", Kp, Kd)  # Verify tuned gains

Why it works: Lower proportional plus derivative damping reduces overshoot and oscillations.
Avoid next time: Tune Kp up slowly, add Kd when you see overshoot.

Debugging 6.12.2 – Loses the line on tight turns

Problem: Turns are too slow or steering saturates at ±1.
Clues: MIX:LR clamps at 0/1 often; SAFE:LOST_LINE prints.
Broken code:

ramp = 0.05  # Changes too small; can’t keep up

Fixed code:

ramp = 0.2  # Allow faster changes per step
base = 0.6  # Keep base speed reasonable
print("DEBUG:MIX ramp", ramp, "base", base)  # Confirm mix settings

Why it works: A larger ramp lets the robot update wheel speeds fast enough to track the curve.
Avoid next time: Balance ramp and base—too fast causes jerk, too slow misses turns.


Final checklist

  • Sensors print normalized values in a single clean snapshot
  • Weighted center and ERR:POS behave as expected (left negative, right positive)
  • PID prints p/i/d/u each cycle and clamps integral and output safely
  • Motor mixing prints LF/RF and uses ramping to avoid jerks
  • Auto‑tune runs a short step test and suggests gain changes
  • Laps and logs record performance for classroom review

Extras

  • 🧠 Student tip: Tape two tracks—one gentle, one tight. Tune Kp/Kd on the gentle, then test the tight and adjust ramp.
  • 🧑‍🏫 Instructor tip: Have teams plot PID:STEP u over time and annotate where overshoot appears; connect plots to gain changes.
  • 📖 Glossary:
    • Weighted center: A single position estimate computed from multiple sensors.
    • Windup: Integral term grows too large when the controller is saturated.
    • Ramp: A limit on how fast outputs can change to stay smooth.
  • 💡 Mini tips:
    • Keep sensors at the same height; uneven spacing skews the center.
    • Start with Ki = 0; add a little only if steady‑state error persists.
    • Print totals and center—those two numbers explain most behavior.
On this page