Embedded control · model-based design

An autonomous driving function, built the way vehicle software is built

A two-wheeled concept car that drives a closed course under its own power. Requirements before architecture, a plant model before any control law, generated embedded C on the target, and three escalating test stages — model, processor, road. Built solo, end to end.

Simulink Stateflow MATLAB Embedded C PID control Arduino / SAMD21 I²C HiL & PiL testing Jenkins
Scope solo, requirements → hardware Outcome autonomous laps, with a documented failure mode

01 — What it is

The system in one page

A differential-drive vehicle on an Arduino Nano 33 IoT with a motor carrier, wheel encoders as the only feedback, and a control stack developed as a model and deployed as generated C. It follows a taped square course without a tether, a host PC, or a radio link — every command and correction is computed on the board.

CAD render of the Arduino Nano 33 IoT mounted on the Nano Motor Carrier
Controller and motor carrier
I2C bus topology: VDD, SDA and SCL with pull-up resistors, connecting the Nano 33 IoT as master to the motor carrier and encoders
I²C bus — one master, everything else on two shared wires

02 — Skills

What this project actually demonstrates

SkillWhere it shows upTools
Model-based designPlant model and control law developed and verified as models before any code was generatedSimulink, MATLAB
State machine designRoute sequencing as a Stateflow chart with relative targets and guard conditions on sensor feedbackStateflow
Embedded CProduction code generated for a SAMD21 target and run untethered on the boardMATLAB Coder, Embedded Coder
Control engineeringTwo PID loops — distance and heading — tuned empirically against a measured plantSimulink Control Design
System identificationMotor characterised by measured sweep rather than datasheet values, including its dead bandMATLAB
Test strategyThree-stage verification ladder, each stage removing one simulation so failures stay attributableMiL / PiL / run-on-board
Requirements engineering17 traceable requirements, each bound to a verification activity before implementationStructured spec
CI / automationVerification retrofitted as a build: static analysis and a requirements traceability gate on a controller defined entirely as codeJenkins, JCasC, Docker, cppcheck

The last row is later work, built on top of the project — see the pipeline page.

03 — Plant

Model the vehicle before controlling it

A controller is only meaningful against a plant. Differential drive gives you two independent wheel velocities; everything the vehicle can do — drive straight, arc, rotate in place — falls out of their sum and difference.

Differential drive kinematics: two wheels with velocities V_l and V_r, wheelbase L, turning about an instantaneous centre of curvature at radius R, with the governing equations
Body velocity, rotation rate and turn radius from the wheel pair. Rotating in place is the V_r = −V_l case, which is exactly what the turn state commands.

The motors were then characterised by measurement rather than datasheet. This matters: the response is not linear, and there is a dead band either side of zero where a PWM command produces no rotation at all. A control law tuned without knowing that will integrate error while the wheels sit still.

Measured DC motor steady-state response: PWM command against speed in rpm, raw sweep and monotonic fit, showing a dead band around zero
Measured steady-state response, 100:1 gearbox. Redrawn from the original motorResponse data — 41-point raw sweep, 31-point monotonic fit.

04 — Control

Closed loop, with encoders as the only truth

Open loop moves the car; closed loop is what makes it arrive. Wheel encoders give measured distance and heading, and two PID controllers drive those errors to zero. There is no map, no external reference, and no absolute position sensor — the vehicle knows only how far its wheels have turned.

Closed-loop signal flow: desired distance minus measured distance gives error, a PID controller produces velocity v, convToWheelVel turns v and w into left and right wheel speeds, and wheel encoders close the loop
The loop, redrawn. Block names are the model's own.
The same closed loop as built in Simulink
The same loop in Simulink — kept as evidence that the diagram above describes something that exists.
Encoder feedback chain: wheel speed in degrees per second integrated to accumulated degrees
Where feedback comes from — and the structural limit that sets up the failure later.

05 — Sequencing

The route is a state machine, not a script

The course is not a trajectory file. It is a Stateflow chart that issues velocity and rotation commands, waits on guards computed from encoder feedback, and advances. The targets are relative — desDist = dist + 30, desAng = ang + 90 — so four repetitions of one four-state cycle close a square, and the same chart scales to any polygon.

square-course loop — chart, action language, and resulting path animated — one lap, 12 s
STATE CHART after(1,sec) [abs(desDist-dist)<0.1] after(1,sec) [abs(desAng-ang)<0.5] loop ×4 StopFirst1 / Pause2 MoveForward1 Pause3 Turn1 Stop1 · after(25,sec) ACTION LANGUAGE — ACTIVE STATE MoveForward1 entry: desDist = dist + 30; during, exit: v = distCtrl(desDist,dist); w = 0; Pause3 entry: v = 0; w = 0; Turn1 entry: desAng = ang + 90; during, exit: v = 0; w = angCtrl(desAng,ang); Pause2 → StopFirst1 entry: v = 0; w = 0; v — commanded forward velocity w — commanded rotation rate both feed convToWheelVel → wl, wr Text is verbatim from the chart; every guard is the model's own. COMMANDED COURSE 4 × (forward 30, pause, turn 90°, pause) The loop closes because desDist and desAng are relative, not absolute.
State names, entry and during actions, and guard conditions are read directly out of the Simulink model. The car is stationary during the pauses and rotates in place during the turn — v = 0 in both.
The StateLogic Stateflow block inside the Simulink model, taking distance and angle from the encoders and producing velocity and rotation rate
The chart in context — encoder feedback in, v and w out, converted to wheel speeds and issued to both motors. Screenshot from the model itself.

06 — Interfaces

The library surface the control code sits on

The vehicle library was used as supplied — sensors, encoders, motors and their managers. Knowing where the boundary sits is the point: everything above it is mine, everything below it is vendor code I did not modify.

UML class diagram of the vehicle library: DistanceReading, DistanceSensor, Encoder, SensorManager, ActuatorManager and DCMotor, with the Sensor1 interface
Class structure of the vendor library, redrawn. Encoder::getCounts() is the single source of feedback for both control loops.

07 — Verification

Three stages, each removing one simulation

The same driving function is tested three times. Each stage replaces one simulated element with the real one, so when a stage fails you know which layer introduced the failure. This is the part that transfers to any embedded programme.

MiL → PiL → run-on-board animated — 9 s
Model-in-the-loop controller: simulated plant: simulated Processor-in-the-loop controller: real target plant: simulated Run-on-board controller: real target plant: physical car Each step removes one simulation. The controller stops being a model at PiL; the plant stops being a model on the board. Nothing else changes between stages, which is what makes a failure attributable.
The value of the ladder is diagnostic: behaviour that survives the first two stages and fails on the third is a physical-world problem, not a control-law problem.
Path followed in model-in-the-loop simulation
Model-in-the-loop — the simulated vehicle closes the square exactly. Replotted.
Processor-in-the-loop, trial 59, the accepted tuning
Processor-in-the-loop — accepted tuning
PASS — model and processor stages both track the commanded course.
The commanded square course with three marked departure points at 30, 50 and 70 percent of the lap
Commanded course, with the three recorded departures
Run-on-board results: three trials leaving the path at 30, 50 and 70 percent complete
Three untethered trials
FAIL — drives autonomously, but leaves the course at 30%, 50% and 70% complete.

08 — Judgement

Reading the failure correctly

Three untethered runs failed. They were preceded by 59 tethered tuning trials, and the failures came progressively later — 30%, then 50%, then 70%. That is not a controller that does not work. That is a controller converging, sampled three times.

The binding constraint was never the control law. It was that every iteration needed a person to change a constant, rebuild, reflash and walk a track — so the project could afford 59 attempts at the cheap stage and three at the expensive one. The honest diagnosis is iteration cost, and the honest fix is automation, not cleverness.

Bar comparison: 59 tethered tuning trials against 3 untethered validation runs
The asymmetry that shaped the result.

The root cause of the drift itself is structural: wheel encoders measure rotation, not position. Dead reckoning integrates error and has no way to notice the vehicle has left the course. Any amount of PID tuning improves the constant; none of it closes that gap.

ChangeWhat it addresses
Retune the integral termDrift accumulated across the course rather than appearing at once
Add a camera and image processingGives an absolute position reference so the vehicle can detect that it has left the track
Use the on-board Wi-Fi for telemetryThe radio went unused. Untethered trials would become as cheap as tethered ones — which is the actual bottleneck

The third row is where the follow-on work went: the verification stages are now a build that runs on every change. That is the pipeline page →