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
| Part | How many? | Pin connection / Notes |
|---|---|---|
| D1 R32 (ESP32) | 1 | USB cable (30 cm) |
| IR reflectance array (3–5 sensors) | 1 set | e.g., A0→Pin 32, A1→33, A2→34, A3→35, A4→36 |
| L298N + TT motors | 1 set | Left IN1→18, IN2→19; Right IN3→5, IN4→23 |
| LED + buzzer (optional) | 1 each | LED → Pin 13, Buzzer → Pin 23 |
| Track line (black tape on white) | 1 | High 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.