- 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.
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.
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.
dist also feeds distCtrl, which is not drawn to keep the lanes readable.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.
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.
v = 0 in both. The drawn path is the commanded course, not a measured one.- 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 outputsvandw. Motion states advance on feedback guards (abs(desDist-dist)<0.1,abs(desAng-ang)<0.5). Pauses advance onafter(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.
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.
- 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
distandangfrom 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.
| View | Question it answers | Artefacts in this project |
|---|---|---|
| Logical | What 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 |
| Process | What 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 |
| Development | How 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 |
| Physical | What 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.
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.
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 purpose | Why no file is mapped |
|---|---|
| HR-F-02 · HR-F-03 · HR-F-05 · HR-F-06 · HR-F-07 | Hardware requirements, satisfied by physical parts, not by a file in the repository |
| SR-F-07 | Satisfied 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 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.
- 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.pyopensdocs/requirements-specification.xlsxas 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.
- Context
- The generated C includes eleven MathWorks and Arduino headers (
MW_ArduinoHWInit.h,xcp.handtmwtypes.hamong 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
missingIncludesuppressed), the traceability gate, and an artefact-integrity check that every generated directory has an entry point andPROVENANCE.mdexists. - 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
Timed open-loop script
Commands on a clock. Simple to simulate, blind to whether the car arrived.
Absolute waypoint trajectory
A list of poses in a fixed frame. Needs the car to know where it is in that frame.
Relative-target state machine
Guards on feedback, targets relative to now. One cycle reused per side.
Behaviour tree
Composable ticked nodes with fallbacks. Suits reactive behaviour; heavier than one square needs.
| Criterion | A · timed script | B · absolute waypoints | C · relative state machine (chosen) | D · behaviour tree |
|---|---|---|---|---|
| Advances on measured progress, not elapsed time | no | yes | yes, via guards | yes |
| Works with encoder-only sensing | yes, blindly | needs absolute pose | yes | yes |
| Error from one side carries into the next | uncorrected | no, targets are fixed | yes, targets chain | depends on leaves |
| Reuse: one pattern for every side | each segment timed | list of points | one cycle ×4 | subtree reuse |
| Fit with Stateflow → generated C | timing chart | table plus tracker | native | not native; hand-built ticks |
| Room for reactive behaviour (obstacles, recovery) | none | replanning needed | state count grows | designed for it |
(b) Feedback and localisation
Encoder dead reckoning
Rotation integrated into distance and angle. Cheap, always available; error grows unobserved.
Camera as absolute reference
Sees the car against the track. Can detect leaving the course; costs image processing.
IMU + encoder fusion
Gyro rate corrects heading drift between encoder updates. Still relative: slows drift, does not bound it.
| Criterion | E · encoders (built) | F · camera | G · IMU + encoders |
|---|---|---|---|
| Hardware on this build | fitted, used | not fitted | IMU on the carrier, unused |
| Can detect that the car left the course | no | yes | no |
| Heading error over a lap | grows | bounded by each fix | grows more slowly |
| Load on a SAMD21 alongside the control loop | trivial | heavy | moderate |
| Change to the layered architecture | none | new sensing layer; guards read absolute pose | fusion block replaces encoder chain |
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.