Designing Your System
Time: 60 min
Background: DRY, SRP, and Code Organization
Two principles prevent code rot: DRY (Don't Repeat Yourself) — one function, called from many places — and SRP (Single Responsibility Principle) — each module does ONE thing. The config.py pattern centralizes all magic numbers with documented reasoning (e.g., "Kp=45, tuned 2024-01-15, see data/kp_tuning.csv"). This way, changing a parameter requires editing exactly one file, not hunting through every module.
Lab Setup
Connect your Pico via USB, then from the picobot/ directory:
Verify in REPL: from picobot import Robot; Robot() → "Robot ready!"
First time? See the full setup guide.
Learning Objectives
By the end of this lab you will be able to:
- Derive control parameters from physical measurements instead of guessing
- Build a simple motor model from timed experiments
- Design a multi-file project structure on paper before writing code
- Implement a configuration file where every number is justified
- Separate concerns so each module has one responsibility
- Verify that a measured-parameter design matches or outperforms magic numbers
You will measure your robot, derive parameters from data, design a module structure, and build it -- in that order.
Where Do These Numbers Come From?
Open up any code from the last few weeks. You will find lines like this:
correction = 32 * error
robot.set_motors(int(80 + correction), int(80 - correction))
weights = [-1.5, -0.5, 0.5, 1.5]
You already know where KP = 32 came from -- you measured it in Precise Turns. You ran trials, collected error logs, and chose the value that minimized overshoot. That number is justified.
But what about the others?
Why 80? Why not 70 or 90?
What does speed = 80 mean in physical terms? How fast does the robot actually travel at PWM 80? Can you answer in centimeters per second?
Why [-1.5, -0.5, 0.5, 1.5]?
These weights determine how the line sensors contribute to the error signal. Where did those numbers come from? Are they arbitrary, or do they reflect something physical?
Some of these numbers were tuned through experimentation -- good. But others were copied from example code, or inherited from a previous semester, or chosen because "they seemed to work." That is the problem.
Magic numbers make your robot fragile. Change the sensor board, swap a motor, or even run on a different surface, and the whole system drifts. You cannot debug what you cannot explain.
This lab is about closing that gap. Every parameter in your system should trace back to either a measurement or a design decision. When someone asks "why 80?" you should be able to answer.
Hands-on tasks
Part 1: Measure Your Robot
Engineering starts with measurement. Before you organize code, you need to know what your robot actually does. Get a ruler, a stopwatch (or use time.ticks_ms()), and your track.
Sensor Geometry
The line sensor array has four infrared sensors spaced along a bar. Their physical positions determine the error weights.
Measure the sensor spacing with a ruler.
Look at the underside of your robot. Find the four IR sensors. Measure the distance from each sensor to the center of the array:
| Sensor | Position from center (mm) |
|---|---|
| S1 (leftmost) | ______ |
| S2 | ______ |
| S3 | ______ |
| S4 (rightmost) | ______ |
How to measure
Find the center point between S2 and S3. Measure each sensor's distance from that center. Sensors to the left get negative values, sensors to the right get positive values.
Typical result: something like -15mm, -5mm, +5mm, +15mm (your robot may differ).
Now normalize these by dividing each by 10:
That gives: -1.5, -0.5, +0.5, +1.5
Do those numbers look familiar?
Compare your measured, normalized weights to the library's default [-1.5, -0.5, 0.5, 1.5]. They should match -- because the library weights were derived from the physical sensor spacing. The "magic" numbers were measurements all along.
This is the core insight: parameters should come from geometry, not guessing. If someone builds a robot with different sensor spacing, they need different weights. Now you know how to calculate them.
Motor Response
The number 80 in set_motors(80, 80) is a PWM command, not a speed. To know how fast your robot actually moves, you need to measure it.
Task 1 -- Build a Speed Table
Place the robot at a starting line. Drive forward at a fixed PWM for a known distance (50 cm works well), and time it.
from picobot import Robot
import time
robot = Robot()
PWM_VALUE = 60 # Change this for each trial
print(f"Testing PWM = {PWM_VALUE}")
print("Place robot at start line. Press Enter when ready.")
input()
start = time.ticks_ms()
robot.set_motors(PWM_VALUE, PWM_VALUE)
print("Press Enter when robot crosses 50cm mark.")
input()
elapsed_ms = time.ticks_diff(time.ticks_ms(), start)
robot.stop()
speed_cm_s = 50.0 / (elapsed_ms / 1000.0)
print(f"PWM {PWM_VALUE}: {speed_cm_s:.1f} cm/s ({elapsed_ms} ms for 50cm)")
Automated version
If pressing Enter feels imprecise, use the ultrasonic sensor aimed at a wall. Start 50 cm from the wall, drive forward, and stop when read_distance() < 5. The timing is then fully automatic.
Run this at four PWM values and fill in the table:
| PWM | Time for 50 cm (ms) | Speed (cm/s) |
|---|---|---|
| 40 | ______ | ______ |
| 60 | ______ | ______ |
| 80 | ______ | ______ |
| 100 | ______ | ______ |
Now you have data. Plot it (on paper is fine -- graph paper, or just sketch the four points). You should see something roughly linear.
What does the data tell you?
Fit a line through your points, either by eye or with a simple calculation. Pick two points \((PWM_1, v_1)\) and \((PWM_2, v_2)\):
$\(m = \frac{v_2 - v_1}{PWM_2 - PWM_1}\)$
$\(b = v_1 - m \cdot PWM_1\)$
Your model is: \(\text{speed} = m \times PWM + b\)
Now SPEED = 80 has meaning: it is approximately ______ cm/s on your specific robot.
Checkpoint
You have a four-row speed table and a linear fit. You can convert between PWM values and physical speed in cm/s. If someone asks "how fast does your robot go at PWM 80?" you can answer with a number and explain how you measured it.
Turn Rate
You already used the gyroscope in Precise Turns to measure heading. Now use it to measure how fast the robot spins at different motor powers.
Task 2 -- Build a Turn Rate Table
from picobot import Robot
from picobot import DataLogger
import time
robot = Robot()
robot.imu.calibrate()
MOTOR_POWER = 60 # Change this for each trial
DURATION = 2.0 # seconds
print(f"Testing turn rate at power = {MOTOR_POWER}")
time.sleep(0.5)
start_heading = robot.imu.heading
robot.set_motors(-MOTOR_POWER, MOTOR_POWER) # Spin in place
time.sleep(DURATION)
robot.stop()
end_heading = robot.imu.heading
total_degrees = abs(end_heading - start_heading)
rate = total_degrees / DURATION
print(f"Power {MOTOR_POWER}: {rate:.1f} deg/s ({total_degrees:.1f} deg in {DURATION}s)")
| Motor Power | Turn Rate (deg/s) |
|---|---|
| 40 | ______ |
| 60 | ______ |
| 80 | ______ |
| 100 | ______ |
Fit a line through these too. Now you have a turn model: \(\omega = m_t \times \text{power} + b_t\).
Why does this matter?
With this model, you can answer questions like "What motor power gives 180 deg/s?" or "How long will a 90-degree turn take at power 60?" These are no longer guesses -- they are calculations from measured data.
Checkpoint
You now have three sets of measurements: sensor geometry, speed model, and turn model. Every number traces back to a physical measurement on your specific robot. This is what engineers call system identification -- determining a system's parameters from experimental data.
Part 2: From Measurements to Design
You have data. Now the question changes from "what are the numbers?" to "how do I organize the code?"
Design on Paper First
Do not open your editor yet. Grab a piece of paper (or a whiteboard) and think about your system.
What are the inputs?
List every sensor your robot has: line sensors, IMU, ultrasonic. What data does each provide? What units?
What are the outputs?
Motors (left/right PWM), LEDs, buzzer. What does each accept?
What processing happens between inputs and outputs?
Line following (P-control), heading control, obstacle detection, state machine logic. Which of these are independent? Which share data?
What parameters exist?
List every number you measured or tuned: Kp, base speed, sensor weights, turn rate model, obstacle threshold, loop timing. Where should each one live?
The Module Diagram
Draw this on paper:
config.py <-- All measured values, one source of truth
|
+-- line_control.py (uses: sensor weights, Kp, base speed)
|
+-- navigation.py (uses: turn model, heading control)
|
+-- safety.py (uses: distance thresholds)
|
+-- main.py (state machine, imports everything)
Each box has one responsibility:
config.py-- stores parameters. No logic, no imports beyond constants.line_control.py-- reads line sensors, computes correction, drives motors. Knows nothing about obstacles or turns.navigation.py-- handles turns using the gyro. Knows nothing about lines.safety.py-- monitors obstacle distance. Knows nothing about where the robot is going.main.py-- the state machine that coordinates everything. Decides when to follow, turn, or stop. Contains no control math.
Two Design Principles
Single Source of Truth: Every parameter lives in ONE place. If KP appears in config.py, it must not also be hardcoded in line_control.py. Every module reads from config. When you retune, you change one file.
Separation of Concerns: Each module handles ONE responsibility. If you need to fix line following, you open line_control.py. If the obstacle threshold is wrong, you open config.py. You never have to understand the whole system to fix one part.
Why design on paper?
Paper forces you to think about structure before syntax. You cannot "just try it" on paper -- you have to reason about dependencies, data flow, and boundaries. This is the difference between designing a system and hacking until it works.
Part 3: Build It
Now open your editor. You will create three files, testing after each one. The rule is simple: never have more than one untested change.
Step 1: config.py
Create config.py with your measured values. Every number gets a comment explaining where it came from.
# config.py -- Robot parameters derived from measurement
# All values measured on [date] for robot #[your number]
# --- Sensor geometry (measured with ruler) ---
# Sensor positions: -15mm, -5mm, +5mm, +15mm from center
# Normalized by dividing by 10
SENSOR_WEIGHTS = [-1.5, -0.5, 0.5, 1.5]
# --- Speed model (measured: PWM -> cm/s) ---
# Linear fit: speed = 0.35 * PWM - 2.1
# At PWM 80: approximately 25.9 cm/s
SPEED_SLOPE = 0.35 # cm/s per PWM unit
SPEED_OFFSET = -2.1 # cm/s (x-intercept correction)
BASE_SPEED = 80 # PWM units (~26 cm/s)
# --- Turn model (measured: power -> deg/s) ---
# Linear fit: rate = 2.8 * power + 5.0
# At power 60: approximately 173 deg/s
TURN_SLOPE = 2.8 # deg/s per PWM unit
TURN_OFFSET = 5.0 # deg/s
# --- Control parameters (tuned in Precise Turns lab) ---
KP = 32
TURN_SPEED = 70
# --- Safety thresholds ---
OBSTACLE_DISTANCE = 15 # cm, stop if closer
DISTANCE_CHECK_MS = 100 # ms between ultrasonic reads
# --- Timing ---
LOOP_PERIOD_MS = 20 # 50 Hz main loop
Use YOUR measurements
The numbers above are examples. Replace SPEED_SLOPE, SPEED_OFFSET, TURN_SLOPE, TURN_OFFSET, and BASE_SPEED with the values you measured in Part 1. If you copy these example numbers, you have learned nothing.
Test: Import config from the REPL to verify there are no syntax errors.
Step 2: line_control.py
Extract line following into its own module. It reads from config.py and knows nothing else about the system.
# line_control.py -- Line following logic
from config import KP, BASE_SPEED
class LineController:
"""P-control line follower. Uses sensor error and Kp from config."""
def __init__(self, robot):
self._robot = robot
def step(self):
"""Execute one step of line following.
Returns the error value, or None if line is lost.
"""
error = self._robot.sensors.line.get_error()
if error is None:
self._robot.stop()
return None
correction = KP * error
self._robot.set_motors(
int(BASE_SPEED + correction),
int(BASE_SPEED - correction)
)
return error
Test: Upload both config.py and line_control.py to the Pico. Then test from the REPL:
>>> from picobot import Robot
>>> from line_control import LineController
>>> robot = Robot()
>>> lc = LineController(robot)
>>> lc.step() # Place robot on line; should return a float
0.25
ImportError?
Both config.py and line_control.py must be on the Pico in the same directory as main.py (usually the root /). If you saved them only on your computer, Thonny will not find them when running on the device.
Step 3: Clean main.py
Now write the state machine. It imports modules and coordinates behavior. It contains no control math and no magic numbers.
# main.py -- Delivery robot state machine
from picobot import Robot
from config import LOOP_PERIOD_MS, OBSTACLE_DISTANCE, DISTANCE_CHECK_MS
from line_control import LineController
import time
# --- Initialize ---
robot = Robot()
robot.imu.calibrate()
line = LineController(robot)
# --- State machine ---
state = "FOLLOW"
state_start = time.ticks_ms()
last_distance_check = 0
cached_distance = 100
def enter_state(new_state):
global state, state_start
print(f"{state} -> {new_state}")
state = new_state
state_start = time.ticks_ms()
def time_in_state():
return time.ticks_diff(time.ticks_ms(), state_start)
def check_obstacle():
"""Non-blocking obstacle check."""
global last_distance_check, cached_distance
now = time.ticks_ms()
if time.ticks_diff(now, last_distance_check) >= DISTANCE_CHECK_MS:
cached_distance = robot.read_distance()
last_distance_check = now
return cached_distance < OBSTACLE_DISTANCE
# --- Main loop ---
try:
while True:
if state == "FOLLOW":
if check_obstacle():
robot.stop()
enter_state("AVOIDING")
else:
error = line.step()
if error is None:
enter_state("LINE_LOST")
elif state == "AVOIDING":
robot.set_motors(-60, -60)
if time_in_state() > 500:
enter_state("FOLLOW")
elif state == "LINE_LOST":
robot.stop()
if time_in_state() > 2000:
enter_state("FOLLOW")
time.sleep(LOOP_PERIOD_MS / 1000)
except KeyboardInterrupt:
pass
finally:
robot.stop()
print("Stopped")
Test: Run main.py. The robot should follow the line exactly as before. The console should print state transitions:
Test after EVERY step
If you create all three files at once and something breaks, you will not know which file caused the problem. Upload and test each file before moving to the next.
Checkpoint
Your project now has at least three files: config.py, line_control.py, and main.py. The robot behaves identically to the single-file version. But now, changing KP requires editing one line in one file -- and the change takes effect everywhere.
Part 4: Verify Your Design
Does the measured-parameter design actually perform better than the original magic numbers? There is only one way to find out: test both.
Task 3 -- Comparison Runs
- Save your new code (measured parameters, modular structure).
- Dig up your old single-file code from a previous lab (the one with magic numbers scattered throughout).
- Run each version 5 times on the same track section.
- For each run, observe: Does it complete the track? How smooth is the line following? Any oscillation?
| Run | Old Code (magic numbers) | New Code (measured params) |
|---|---|---|
| 1 | ______ | ______ |
| 2 | ______ | ______ |
| 3 | ______ | ______ |
| 4 | ______ | ______ |
| 5 | ______ | ______ |
What to record
Use the DataLogger from Precise Turns to log the error signal during each run. Compare the average absolute error between old and new. If you do not have time for full data logging, a simple pass/fail and subjective smoothness rating (1--5) is enough.
The measured-parameter version should perform at least as well as the magic-number version. If it performs worse, your measurements may be off -- go back and re-measure. The real advantage is not just performance, though. It is that you can explain every number. When something breaks, you know where to look.
Checkpoint
You have run both versions and compared results. You can articulate why the measured-parameter approach is more maintainable even if raw performance is similar.
Common Embedded Bugs -- Quick Reference
These are not architecture problems, but they show up often enough that you should recognize them on sight. Shown as before/after diffs.
# Timer overflow -- works for 49 days, then breaks
elapsed = time.ticks_ms() - start_time # BAD
elapsed = time.ticks_diff(time.ticks_ms(), start_time) # GOOD
# Variable scope -- state never changes
def handle_button():
state = "RUNNING" # Creates LOCAL variable!
def handle_button():
global state
state = "RUNNING" # Modifies the GLOBAL variable
# Missing cleanup -- motors keep running after Ctrl+C
try:
while True:
robot.forward(80)
except KeyboardInterrupt:
print("Stopped") # Motors still running!
try:
while True:
robot.forward(80)
except KeyboardInterrupt:
print("Stopped")
finally:
robot.stop() # Always runs, even on crash
# Float equality -- condition is never true
if error == 0.0: # Almost never exactly 0.0
print("Centered!")
if abs(error) < 0.01: # Tolerance-based comparison
print("Centered!")
Keep this page bookmarked. When something weird happens, check these patterns first.
What You Learned
This lab was about design thinking, not just code organization. The process you followed is how professional engineers approach embedded systems:
- Measure -- Determine the physical properties of your hardware
- Model -- Fit simple mathematical relationships to the data
- Design -- Sketch the software structure on paper
- Implement -- Build it one module at a time, testing each step
- Verify -- Compare the result against the previous approach
This process has a name in industry: system identification followed by model-based design. Automotive engineers use it to design ABS braking systems. Aerospace engineers use it for flight controllers. Robotics companies use it for every product they ship.
The difference between an engineer and someone who just writes code is this: the engineer can explain why every parameter has its value, and can re-derive that value from first principles if the hardware changes.
Your config.py checklist:
- [ ] Every number has a comment explaining its origin
- [ ] Sensor weights match measured geometry
- [ ] Speed values trace to timed experiments
- [ ] Kp comes from tuning data
- [ ] No magic numbers remain in any other file
Challenges
Challenge: Automatic Calibration Script
Write a script that runs the speed and turn-rate measurements automatically, fits the linear models, and generates config.py. This is what factory calibration looks like in real products.
Challenge: Add a Navigation Module
Create navigation.py that uses your turn model to execute precise turns. Given a target angle, it should calculate the required motor power and duration from the measured turn-rate model, then verify with the gyro.
Challenge: Temperature Compensation
Motor speed varies with temperature (and battery voltage). Run your speed calibration at the start of a session and at the end. How much did the model change? Can you add a periodic re-calibration step?