Case study 02 · M.Eng thesis project · embedded control software architecture

A concept car, layered from the route down to the vendor bus

A two-wheeled battery-electric concept car on an Arduino Nano 33 IoT that drives a taped square course with no tether, host PC or radio link. The architecture is a layered embedded control stack: a state machine sequences the route, two PID loops close on encoder feedback, a kinematic block turns body commands into wheel speeds, and everything below an I²C bus belongs to the vendor. This page is about that shape, how it was traced from requirement IDs to files, and the architectures it did not use.

Layered embedded control Stateflow sequencing · relative targets Two PID loops Simulink · Embedded Coder I²C · vendor co-processor V-model · MiL / PiL / run-on-board Jenkins traceability gate (later work)
Context M.Eng thesis project, RPTU Kaiserslautern-Landau Role solo, requirements through hardware Target Nano 33 IoT (SAMD21) on a Nano MotorCarrier
17
functional requirement IDs in the spec, read by the CI gate
59 → 3
tethered tuning trials against untethered validation runs
3
verification stages, each removing one simulation
64.7%
trace coverage: 11 of 17 IDs resolve to files, 0 broken links

01 — Architectural style

Layered embedded control, with ownership ending at the bus

Each layer only talks to the one below it. The route logic never sets a wheel speed; it asks for a distance or an angle. The control law never touches a motor; it produces a body velocity v and a rotation rate w. One kinematic block, convToWheelVel, converts those into wheel speeds wl and wr. Below that sits the Arduino device library, used unmodified, and an I²C bus to a co-processor on the MotorCarrier that closes the innermost motor loop.

Feedback runs the other way. Encoder counts come up through the library, are integrated into accumulated distance and angle, and feed both the PID error terms and the state-machine guards. Wheel encoders are the only feedback in the system.

logical view — layers, ownership, signal directioninteractive — gold commands down, red feedback up
L4 · SEQUENCING L3 · CONTROL LAW L2 · KINEMATICS ▲ MINE ▼ VENDOR, UNMODIFIED L1 · DEVICE LIBRARY BUS L0 · CO-PROCESSOR StateLogic — Stateflow chart MoveForward1 · Pause3 · Turn1 · Pause2 — relative targets distCtrlPID · desDist, dist → v angCtrlPID · desAng, ang → w convToWheelVelv, w → wl, wr (deg/s) Encoder chainintegrate → deg DCMotor · ActuatorManagerArduino device library · © Arduino AG, LGPL-2.1 EncodergetCounts() I²C · 100 kHz · Nano 33 IoT is master MotorCarrier co-processorcloses the inner motor loop on its own schedule Motorswith encoders vw wlwr counts dist, ang
Block and state names are the model's own; the vendor class names come from my UML documentation of the library. Layer grouping is how I would draw it for review. The model does not label its subsystems L0–L4, and dist also feeds distCtrl, which is not drawn to keep the lanes readable.
Why the bus is the architectural boundary

Below the bus the interface was fixed by someone else. So I documented the library as a class diagram before building any control model. That made its constraints known while the architecture could still move: fixed-point gain types, a motor loop running on the co-processor's own schedule, and encoder counts rather than angles. Encoder::getCounts() is the single source of feedback for both control loops.

ADR-01 · Run the controller on-board; leave the inner motor loop on the vendor co-processoraccepted · built · run untethered
Context
The Nano MotorCarrier has its own co-processor running the motor loop, reached over I²C. The device library above it is Arduino AG code under LGPL-2.1. The project goal was a car that drives with no host PC and no radio link.
Decision
Sequencing, both PID loops and the kinematic conversion are modelled in Simulink and deployed to the Nano as generated C. The library and the co-processor loop are used as supplied, behind a documented class interface. Generated target contract: bare metal, I²C at 100 kHz, external mode over XCP on serial at 115200 for the tethered stage.
Rejected
Keep the controller on the host (in hindsight): that is the processor-in-the-loop setup, and the point was to cut the tether. Modify or replace the vendor library: it would have moved ownership below the bus for no control benefit, and made the provenance split impossible to state cleanly.
Consequences
A clean authorship line. Every file in the repository was hashed against the stock kit, and stock files were excluded. The cost is that the inner loop's timing is not mine to tune, and the controller can only see what the library exposes.

02 — Behavioural view

The route is a state machine, not a script

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

runtime view — chart, action language, resulting pathanimated — 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 guards are read directly out of the Simulink model. The car is stationary during pauses and rotates in place during the turn: v = 0 in both. The drawn path is the commanded course, not a measured one.
ADR-02 · Sequence the route as a state machine with relative targetsaccepted · built · passed MiL and PiL
Context
The model-in-the-loop stage drove the vehicle from a timing chart of commanded linear and angular velocity. On the physical car a command against time says nothing about whether the car has actually arrived.
Decision
A Stateflow chart, StateLogic, takes distance and angle from the encoders and outputs v and w. Motion states advance on feedback guards (abs(desDist-dist)<0.1, abs(desAng-ang)<0.5). Pauses advance on after(1,sec). Targets are set on entry, relative to where the car is.
Rejected
In hindsight: keep the timing chart: it cannot wait for a slow wheel. Absolute waypoints: they need an absolute position the car does not have. Section 06 weighs both.
Consequences
One cycle is reused four times, and each guard is a readable, testable condition. The trade: a relative target starts from wherever the last state ended, so any error in one side carries into the next.
The same habit, at production scale

Treating the Stateflow chart as architecture, not glue, carried straight into production work. On the FCEV truck, eAxle control logic and the supervisor's vehicle modes and sequencing were built the same way: explicit states, explicit guards, generated C.

03 — Verification architecture

Three stages, each removing one simulation

The same driving function was tested three times. Each stage swaps one simulated element for the real one, so a failure can be pinned to the layer that introduced it. The controller stops being a model at PiL; the plant stops being a model on the board.

MiL → PiL → run-on-boardanimated — 9 s
EACH STAGE REMOVES ONE SIMULATION, SO A FAILURE STAYS ATTRIBUTABLE Model-in-the-loop controller: simulatedplant: simulated Processor-in-the-loop controller: real targetplant: simulated Run-on-board controller: real targetplant: physical car PASS · square closed PASS · after 59 trials FAIL · off course 30/50/70% Survives MiL and PiL, fails on the board → a physical-world problem, not a control-law problem.
Verdicts as recorded. The three run-on-board trials left the course at 30%, 50% and 70% complete, with PID constants and speed targets adjusted and the firmware redeployed between runs.
ADR-03 · Wheel encoders as the only feedback (dead reckoning)accepted · built · limit found on the board
Context
The encoders came with the motors and were reachable through the library. Both loops and every motion guard needed a measured distance and angle.
Decision
Integrate wheel speed into accumulated degrees and derive dist and ang from that. No map, no external reference, no absolute position sensor.
Rejected
Nothing was formally weighed at the time. The write-up names a camera as the follow-up. Section 06 is that comparison, done afterwards.
Consequences
Enough to pass MiL and PiL. On the board it is a structural limit: encoders measure rotation, not position. Dead reckoning integrates error and cannot notice that the car has left the course. Each run got further (30%, 50%, 70%), which reads as a controller still converging. But no amount of tuning closes the gap between rotation and position.

04 — Documented views

The project's artefacts, sorted into Kruchten's 4+1 views

I learned the 4+1 model, C4 and arc42 in M.Eng coursework. They were not used as a template when this project was built. The table below applies 4+1 afterwards, as documentation: each view is filled only with artefacts that actually exist.

ViewQuestion it answersArtefacts in this project
LogicalWhat are the parts and their responsibilities?Layered decomposition (initial split, then layered redecomposition); UML class diagram of the vendor interface; PlantUML closed-loop distance and MiL data-flow diagrams
ProcessWhat runs when?StateLogic Stateflow chart; generated bare-metal single-rate scheduler (rt_OneStep with an overrun flag); co-processor motor loop on its own schedule
DevelopmentHow is the software organised and built?models/*.slx → firmware/generated/ via Embedded Coder; .slx/.mlx marked binary lockable; PROVENANCE.md mine/vendor split by SHA-256
PhysicalWhat hardware does it run on and how is it connected?Nano 33 IoT as I²C master at 100 kHz; MotorCarrier co-processor; XCP on serial at 115200 for the tethered stage; generated MW_target_hardware_resources.h
Scenarios (+1)Which use cases tie the views together?Square course, 4 × (forward 30, turn 90°); run at MiL, PiL and run-on-board against the same chart

Coursework vs. project: the frameworks (4+1, C4, arc42) and a DOORS → Enterprise Architect traceability exercise on a team adaptive-cruise-control project are M.Eng coursework, not this project. This project's requirements lived in a spreadsheet with structured IDs, and its UML was written as PlantUML source.

05 — Traceability

From the V on paper to a gate that fails the build

Before implementation started, each design activity on the left arm of the V was paired with the verification activity on the right arm at the same height. Requirements were tagged with IDs (HR-F-xx hardware, SR-F-xx software, SY-F-xx system) and a verification table closed them at the end.

V-model — decomposition against verificationanimated — evidence returns, bottom rung first
RequirementsSystem designArchitectureFunctional design Implementation Model-in-the-loopProcessor-in-loopRun-on-boardVerification table planned first · closed at the end DECOMPOSITION ↓↑ VERIFICATION
Pairing as drawn in my V-model figure, where tooling was also mapped per stage: Excel for requirements, Visio and PlantUML for design, MATLAB/Simulink for implementation and test, Data Inspector for signal comparison. The write-up records the MiL result as passing the ±1% path criterion of SR-F-04.

The gate that was built later

The original verification table was written once, by hand, at the end. As later, separate work I retrofitted the right arm as CI: a Jenkins controller defined as code runs a traceability gate on every build. It reads requirement IDs directly out of the spreadsheet, resolves each against ci/trace_map.yml, and checks that every mapped file still exists.

traceability gate — spec → map → artefact → verdictinteractive — auto-plays, or choose a case
Requirements specdocs/…specification.xlsx trace_map.ymlID → artefact paths Artefact existsmodels/distanceClosedloop_hw.slx PASSexit 0 SR-F-02 ID READ FROM THE SPEC → LOOKED UP IN THE MAP → FILE EXISTS → TRACED requirements: 17 traced: 11 untraced: 6 HR-F-02 HR-F-03 HR-F-05 HR-F-06 HR-F-07 SR-F-07 broken links: 0 coverage: 64.7% (baseline 64.7%) PASS # build #1 output No file at mapped pathrenamed → distanceClosedLoop_hw.slx FAILexit 1 SR-F-02 ILLUSTRATIVE · A RENAME ORPHANS SR-F-02 → BROKEN LINK → BUILD FAILS requirements: 17 traced: 10 untraced: 6 HR-F-02 HR-F-03 HR-F-05 HR-F-06 HR-F-07 SR-F-07 broken links: 1 SR-F-02 -> missing models/distanceClosedloop_hw.slx coverage: 58.8% (baseline 64.7%) FAIL: requirements point at artefacts that no longer exist. GATE RULES broken linkalways fails coverage below baselinefails coverage incompletedoes not fail baseline = real number, a ratchet
The pass-case console text is copied from the gate's real output. The rename is illustrative: its console lines are what trace_check.py prints under that condition (the map lists two files for SR-F-02, and only the model is missing). A broken link is checked first, so the build fails before coverage is compared.
Untraced on purposeWhy no file is mapped
HR-F-02 · HR-F-03 · HR-F-05 · HR-F-06 · HR-F-07Hardware requirements, satisfied by physical parts, not by a file in the repository
SR-F-07Satisfied by material in the written dissertation, which is not in the repository

Mapping those six to something just to raise the percentage would defeat the gate. That is why the baseline sits at 64.7% and not 100%. To raise coverage honestly: add the artefact, add the mapping, then raise the baseline.

A requirement discovered the hard way

A MATLAB release update moved a folder on the critical path and silently broke the link to the Arduino support library. Several days of debugging with MathWorks support produced a requirement nobody had written down: the Arduino library and the Simulink library must stay compatible for the life of the project. The closest written requirement, SR-NF-01, only asked for "version 2020A or later", an open range. The traceability lesson: toolchain version pinning is a requirement with an ID, not an implementation detail. In the pipeline design it becomes a pinned toolchain line that fails on the first build. That job is designed, not built.

ADR-04 · The gate reads IDs from the spec itself, not from a transcriptionlater work · built · runs in CI
Context
A copy of the requirement list kept for CI can drift from the spec it was copied from, and then the gate checks the wrong thing.
Decision
trace_check.py opens docs/requirements-specification.xlsx as a zip, pulls IDs from its shared-strings table with a pattern match, and parses the map with a minimal YAML reader, so it runs on a bare Python image. Results are published as JUnit: untraced IDs as skipped, broken links as failures.
Rejected
A CSV or hand-kept ID list: it can drift. Failing below 100% coverage: that means a permanently red build, or mappings invented to turn it green.
Consequences
The spec is the input. Deleting or renaming an artefact under a requirement is caught on the next build. Known limit: the ID pattern matches functional IDs only, so the spec's non-functional IDs (HR-NF, SR-NF, SY-NF) are not counted in the 17.
ADR-05 · No firmware compile joblater work · accepted
Context
The generated C includes eleven MathWorks and Arduino headers (MW_ArduinoHWInit.h, xcp.h and tmwtypes.h among them). They are vendor files and are deliberately not in the repository.
Decision
Run only what can verify artefacts that are present: static analysis over the generated sources (cppcheck, with missingInclude suppressed), the traceability gate, and an artefact-integrity check that every generated directory has an entry point and PROVENANCE.md exists.
Rejected
A compile stage against stubbed headers: a green firmware build that cannot link would measure nothing.
Consequences
Honest but partial CI. A real compile belongs on a labelled agent carrying gcc-arm-none-eabi, once the toolchain is available there.

06 — Contrast

Other ways to sequence the route, and to know where the car is

Two decisions shaped the result more than any gain value: how the route is sequenced, and what the car uses to know where it is. Here each one sits next to its alternatives.

(a) Sequencing

A · where MiL started

Timed open-loop script

Commands on a clock. Simple to simulate, blind to whether the car arrived.

B · analysis

Absolute waypoint trajectory

A list of poses in a fixed frame. Needs the car to know where it is in that frame.

C · chosen

Relative-target state machine

Guards on feedback, targets relative to now. One cycle reused per side.

D · analysis

Behaviour tree

Composable ticked nodes with fallbacks. Suits reactive behaviour; heavier than one square needs.

CriterionA · timed scriptB · absolute waypointsC · relative state machine (chosen)D · behaviour tree
Advances on measured progress, not elapsed timenoyesyes, via guardsyes
Works with encoder-only sensingyes, blindlyneeds absolute poseyesyes
Error from one side carries into the nextuncorrectedno, targets are fixedyes, targets chaindepends on leaves
Reuse: one pattern for every sideeach segment timedlist of pointsone cycle ×4subtree reuse
Fit with Stateflow → generated Ctiming charttable plus trackernativenot native; hand-built ticks
Room for reactive behaviour (obstacles, recovery)nonereplanning neededstate count growsdesigned for it

(b) Feedback and localisation

E · built

Encoder dead reckoning

Rotation integrated into distance and angle. Cheap, always available; error grows unobserved.

F · write-up follow-up

Camera as absolute reference

Sees the car against the track. Can detect leaving the course; costs image processing.

G · analysis

IMU + encoder fusion

Gyro rate corrects heading drift between encoder updates. Still relative: slows drift, does not bound it.

CriterionE · encoders (built)F · cameraG · IMU + encoders
Hardware on this buildfitted, usednot fittedIMU on the carrier, unused
Can detect that the car left the coursenoyesno
Heading error over a lapgrowsbounded by each fixgrows more slowly
Load on a SAMD21 alongside the control looptrivialheavymoderate
Change to the layered architecturenonenew sensing layer; guards read absolute posefusion block replaces encoder chain
When I would choose differently

If the task grew obstacles or recovery behaviour, I would move sequencing to D, a behaviour tree, and keep the PID and kinematic layers unchanged beneath it. If a camera gave the car an absolute pose, I would switch from relative targets to B, absolute waypoints, because the chaining error in C only exists when nothing re-anchors position. Given the same parts again, I would try G first: the IMU is already on the carrier, and fusion only replaces the encoder-chain block.

None of that fixes the real bottleneck. The write-up names three follow-ups: retune the integral term, add a camera, and use the on-board Wi-Fi for telemetry. The third matters most, because untethered trials would become as cheap as tethered ones.

Scope, stated plainly: C and E are what I built and tested. A is how the model-in-the-loop stage was driven, from a timing chart. F, together with retuning and Wi-Fi telemetry, is named as future work in the write-up but was not built. B, D and G, and every fit rating in both tables, are analysis done afterwards, not systems I delivered or measured.

Full published write-up, with the original figures, trial data and failed runs: raengineered.github.io/concept-car.