Skip to content
Embedded portfolio · 2026

From noisy sensor to torque command, on time.

Hi, I'm Maneet. The work I enjoy most is where software intersects with physics while constrained by real safety problems. I have written software that reads sensor data, filters it, and sends commands to inverters in real-time at UBC Formula Electric and love learning more about embedded systems and vehicle dynamics. Recently, I have been working on validation projects and actively learning more about testing & validation of embedded systems.

Open to
Embedded firmware and controls developer
Open to co-ops & internships in Vancouver, North America or Europe.
Currently
Vehicle Controls & LV Firmware Developer
Studying
BASc Computer Engineering
University of British Columbia, 2029
Working in
Embedded C / C++ARM Cortex-M4FreeRTOSCANDSPARMv7-M assemblyVehicle DynamicsSimulink/MATLAB
First-order low-pass
Raw sampleFiltered
Sensor noise and the odd dropout go in; what comes out is the shape a controller can act on, a few milliseconds behind the world. Deciding how many milliseconds is most of the job.
Measured · with method

Results of some of my work:

93%
Average IMU noise power removed
Averaged over 20 controlled bench runs with the team's shared filter library, so vehicle dynamics control acts on the car's motion, not sensor noise
20ms
Typical group delay held
Same 20 runs, 80 ms at worst: within the delay budget the controller was given, so the cleaner signal still arrives in time to act on
Selected systems

Five things I built

Each one states its problem, what I did and how it was checked. Open a system for the architecture, the decisions and the drawings.

01UBC Formula Electric · Sep to Dec 2025

One validated filter library in place of a filter per ECU

Role
Firmware developer, vehicle controls team
Stack
C++, GoogleTest
Timeline
Sep to Dec 2025
Problem
Sensor data on the car is noisy, and both the battery management system and vehicle dynamics need it smoothed on their own ECUs. This led to each subteam writing their own filter implementation, which was a maintenance and verification burden and made it redundant.
Goal
Build a single source of truth low pass filtering library so any subsystem can use the same implementation so it is easier to test, debug and optimise.
Approach
One centralised library behind a single C++ interface: each subsystem creates the filter type it needs and configures its cutoff and sample rate, instead of writing one. That made the choice of filter family a decision for every ECU at once, so it had to suit a controller. Chebyshev Type I and elliptic filters roll off faster, but only by putting ripple in the passband, and a controller reads that ripple as a disturbance to correct. Butterworth is a little slower but maximally flat in the passband, so the part of the signal the controller acts on comes through without being reshaped.
Outcome
Built EMA, first-order Butterworth and biquad (Direct Form I IIR) filters behind that interface, with coefficients from the bilinear transform of the analog Butterworth prototype, covered by GoogleTest unit and integration tests. The BMS and vehicle dynamics now call the same library across multiple ECUs, and across 20 controlled bench runs it cut IMU noise power by 93% on average, holding group delay to 20 ms typical and 80 ms worst case.
C++DSPIIR Digital FiltersMaintainability & ScalabilityGoogleTest
One cutoff, two orders
the second order arrives laterand settles flatterraw inputfirst ordersecond ordera 6 Hz disturbance on the step, one cutoff at 2 Hz, sampled at 100 Hz

Both outputs come from running the library's own difference equations rather than from sketching a curve, so the flatter settling and the extra delay are properties of the coefficients and not of the drawing. It is a demonstration on a synthetic signal and not a measurement; the measured numbers are in the result above.

The interface, where filters go wrong and what I learnedOpenClose
One interface, many callers
CALLERSONE INTERFACEBEHIND ITBMSits own configurationVEHICLE DYNAMICSits own configurationONE CONFIGURABLE APIapp_sensor_filter.cppEMAexponential moving averageBUTTERWORTHfirst order, bilinearBIQUAD DF1second order ButterworthCallers choose and configure a filter; none of them writes one.

The library is the middle box. A fix to any of the three filters lands in one file, and every caller inherits it without being opened.

Three filters, one interface

An exponential moving average, a first-order Butterworth and a second-order Butterworth biquad in Direct Form I, chosen per caller by configuration. The Butterworth coefficients are the bilinear transform of the analog prototype (Q=1/2Q = 1/\sqrt{2}, pre-warped), which is what the library header points at: maximally flat through the passband, then 20 dB per decade per order. That is why a second-order output comes out straighter, and also why it arrives later.

Magnitude, same H(z)
-3 dBfc 2 Hz6 Hz in the plot above0-480.51251020first order, ~20 dB/decadesecond order, ~40 dB/decadebilinear transform of the analog Butterworth prototype, same coefficients as the traces

The 6 Hz line is the disturbance in the time plot. The gap between the two traces there is the extra smoothness, and the extra group delay is the cost of having a steeper slope to buy it with.

Where digital filters actually go wrong

The difference equations were the easy part. Most of my time went into how the filters start up and recover.

If a filter's delay line starts at zero, the first real sample looks like a step input, and the output shows a transient that has nothing to do with the car. To avoid that, every constructor primes the delay line with an initial value. For the biquad, that means setting all four state variables (x[n−1],x[n−2],y[n−1],y[n−2]x[n-1], x[n-2], y[n-1], y[n-2]) so a constant input gives a constant output from the first sample.

The same problem shows up after a sensor dropout or a fault: the filter keeps averaging old data that no longer reflects what the car is doing. So each filter has a reset() that re-primes the state from the most recent input, so stale samples don't leak into the output.

Reset is cheap because the biquad is Direct Form I. Its state is just the last two inputs and the last two outputs, so re-priming takes four assignments. In Direct Form II, the state is an intermediate signal, so a reset would mean solving for its steady-state value instead of copying the input. Direct Form I also let me check every line directly against the difference equation, which made review simpler for both my lead and I.

What it rejects, and what it leaves to the caller

The constructors assert on configurations that have no valid answer: a cutoff or sample rate of zero or less, an EMA α\alpha outside [0,1][0, 1], or a biquad cutoff at or above fs/2f_s/2. At that point the prewarp term tan⁡(πfc/fs)\tan(\pi f_c / f_s) blows up and the coefficients stop meaning anything.

That fs/2f_s/2 limit is the Nyquist–Shannon sampling theorem. A signal sampled at fsf_s can only represent frequencies below fs/2f_s/2; anything above that folds back down and shows up as a lower frequency that was never there. A cutoff past that point asks the filter to separate frequencies the samples can't contain, so I check for it at construction instead of letting it build a filter that quietly misbehaves. The filter can't undo aliasing that already happened when the sensor was sampled, though. That's set by the sample rate each subsystem chooses.

Everything else is allowed, including settings that are legal but come with costs. A very low cutoff relative to the sample rate pushes the poles toward the unit circle, where float rounding starts to matter. Lower cutoffs also add delay. I documented both in the spec instead of banning them, because each subsystem knows its own task rate and latency budget and the library doesn't.

The filter doesn't clamp values or detect faults either. Some subsystems need exactly what the sensor reported, so deciding what counts as out of range or invalid is up to the caller. The filter only filters.

How it was measured

The filter modules are covered by GoogleTest unit and integration tests. I then ran the library on IMU data across 20 controlled bench runs. On average it removed 93% of the noise power, with group delay at 20 ms typical and 80 ms worst case.

I report those two numbers together because they're one tradeoff. Every choice of filter order and cutoff gives you smoothness at the cost of delay, so each team picks the balance that fits their subsystem and filters their own inputs.

What I learned

This was my first task at UBC Formula Electric. I wrote it in C first, then ported it to C++ when the team's firmware moved over, and nothing broke in the port.

It's where I learned how an analog filter becomes a digital one (Laplace transform, transfer functions, the bilinear transform), and how the cutoff and sample rate together set the group delay a controller has to live with. I also learned to write GoogleTest suites, and to bench test a filter and measure its delay directly instead of just reading a plot.

The lesson that stuck was about the people using it. Every subsystem has its own constraints, so the design that worked was the one that rejected only what was impossible and documented everything else.

02UBC Formula Electric · Jan to Apr 2026

Moving the car's 100 Hz sensor filtering onto dedicated silicon

Role
Firmware developer, vehicle controls team
Stack
C++, STM32 HAL, STM32H733, Q1.15 fixed point
Timeline
Jan to Apr 2026
Problem
The vehicle controller runs on an STM32H733 under FreeRTOS, and its safety-critical control tasks filter their own sensor data in software at 100 Hz. Every cycle a task spends filtering is a cycle between it and its deadline.
Goal
Move that filtering onto the chip's Filter Math Accelerator (FMAC), hardware that was already on the board and that nothing was using, so the same task does the same work in fewer CPU cycles.
Approach
Wrote a shared C++ driver for the FMAC, developed in parallel with the boards it was written for, and took it through three rounds of review. It runs synchronously: the control task hands the FMAC its sample and polls for the result instead of yielding to the scheduler. Wired it into the IMU code to filter gyro Z with a first-order Butterworth, because yaw rate feeds the yaw rate controller directly, and fixed what testing on hardware turned up.
Outcome
Validated in Vector CANoe against real IMU and GPS data. Gyro Z is now filtered by the FMAC instead of in software, so the control task spends fewer cycles on it and finishes further from its deadline. Measuring exactly how many cycles is the next step.
C++STM32H5/7FMACFixed PointDriversSTM32 HALFreeRTOSVector CANoe
One control task, two ways to filter
GYRO Z FILTER IN SOFTWARECONTROL TASKSAME TASK, FILTERING ON THE CPUGYRO Z FILTER ON THE FMACCONTROL TASKSAME TASK, ON THE FMACSLACK BEFORE THE DEADLINEDEADLINEThe task waits on the FMAC instead of yielding: for a filter this small, a context switch costs more.

Nothing runs in parallel. The same task finishes sooner, and the time saved becomes room before its deadline.

Why it runs synchronously, fixed point, what broke on hardware and what I learnedOpenClose
What the FMAC is

The FMAC is a fixed-function unit on the STM32H7 that runs FIR and IIR filters in hardware. It has its own local memory for input samples, coefficients and outputs, and it works in 16-bit fixed point. Once it's configured and fed data, it does the multiply-accumulates itself, so the CPU isn't spending instructions on the arithmetic. It was already on our board, and nothing was using it.

Why it was worth a driver

A filter running at 100 Hz is a small cost, but it's paid a hundred times a second for as long as the car is on, inside the control tasks themselves. A small, constant load like that is easy to ignore and hard to win back later, and the tasks paying for it are the safety-critical ones. Moving the filter into hardware lets each task do the same work in fewer cycles, and the cycles saved become margin between the task and its deadline.

Why it runs synchronously

The FMAC could run asynchronously: the task hands over a sample, blocks, and is woken by an interrupt or a DMA completion when the result is ready, while FreeRTOS runs something else in between. The driver deliberately doesn't do that.

Each call filters a very small amount of data. A first-order IIR needs the current sample and the previous output, and nothing else, and a short FIR window would be in the same position. Going asynchronous costs two context switches per sample: saving the waiting task's registers, choosing and running another task, then restoring the first one when the interrupt fires. For a filter this small, that overhead would cost more than the arithmetic it lets another task overlap with.

So the control task polls the FMAC and waits, and the wait is short. The trade is that the CPU does nothing else during that wait, which is the right call while the filter costs less than a context switch. That reasoning still has to be backed by numbers: profiling the filter in software, on the FMAC, and a FreeRTOS context switch on this chip is the next step, and if a larger filter ever moves onto the FMAC, those numbers are what decide whether it stays synchronous.

Fitting a filter into 16 bits

The FMAC uses Q1.15 format: every value is a 16-bit integer representing a number between −1 and just under +1. A gyro reading in degrees per second doesn't fit that range, so the driver scales each sample by the sensor's full scale (±250\pm 250 °/s), filters it, and scales it back.

The edge of that range is where it broke. At exactly full scale, 1.0×327681.0 \times 32768 is one past the largest int16, and the cast wraps it to −32768-32768. The reading flips sign exactly when the sensor is maxed out. The driver now clamps to the largest value Q1.15 can hold before converting, and the FMAC's saturation mode is enabled so an overflow inside the filter clips instead of wrapping.

The coefficients come from the same Butterworth formula as my filter library, with one sign flipped. The library stores a1a_1 for y[n]=…−a1y[n−1]y[n] = \ldots - a_1 y[n-1], but the FMAC computes +A1y[n−1]+A_1 y[n-1], so the driver passes in −a1-a_1. For a first-order filter at a 10 Hz cutoff and 100 Hz sample rate, every coefficient is inside ±1\pm 1, so no gain shift is needed. That is why it's first-order: a second-order Butterworth at the same cutoff has a1≈−1.14a_1 \approx {-1.14}, which doesn't fit Q1.15 without a gain shift.

What only showed up on hardware

Getting it running on hardware was the hard part, because the boards were being developed at the same time as the firmware. A fault could be in my code, in the CubeMX configuration (an NVIC priority, for example), or in the hardware itself. The symptoms ranged from no IMU data to overflow errors to hard faults. I narrowed them down by reading registers in GDB and probing the board until only one explanation was left.

Three of the bugs were mine, and all three came from HAL functions not doing what their names suggest.

HAL_FMAC_AppendFilterData doesn't copy the sample. It keeps the pointer and reads it during the next poll. My sample was a local variable that was gone by then, so I moved the input and output buffers into the driver object, where they outlive the call.

HAL_FMAC_FilterPreload loads the filter's history, not its coefficients, and I had been passing it the coefficient arrays. It now preloads P−1P - 1 zeros instead of PP, because the extra slot was feeding a phantom zero into the pipeline.

The poll clears its output pointer on every call, and the first poll often only finishes writing the input. So the driver re-registers the output buffer on each pass and keeps polling within its own time budget, instead of treating "not ready yet" as a failure.

When it fails

The first version waited on HAL_MAX_DELAY, which never times out. If the peripheral stopped responding, the calling task would hang. It's now a 100 ms timeout that returns an error the caller has to handle.

If a sample can't be appended, the handshake with the FMAC has fallen out of sync. The driver keeps its own copy of the filter configuration, because the caller's coefficient arrays are usually temporaries. That lets it stop, reconfigure and restart the filter, then retry the sample once before giving up.

On the IMU side, only valid readings go in. A failed SPI read is skipped instead of being pushed into the filter, where one bad value would stay in its history and skew every output after it.

What I learned

This was my first time writing firmware below the application layer, in the hardware abstraction layer instead of app code. The hardest part was the driver itself: reading the reference manual, working out how the peripheral actually behaves, and turning that into a configuration that runs.

Debugging taught me the rest: stepping through registers in GDB, probing the board to rule hardware in or out, how an RTOS schedules tasks, how interrupts and their priorities interact, and how much of a firmware bug lives in configuration rather than code.

This project is what got me into firmware. The filter library taught me the maths. This one taught me that I like the part where the maths meets the silicon.

03Personal project · In progress

A preemptive kernel for Cortex‑M4, written from scratch

Role
Everything above the register map
Stack
C, ARMv7-M assembly, CMSIS, GDB, OpenOCD
Timeline
Aug 2026 to present
Problem
I use FreeRTOS at UBC Formula Electric, and a port layer is hard to understand from the outside. I wanted to know exactly what it was doing on my behalf.
Goal
Write a preemptive kernel for an STM32F302R8 with no HAL, no CubeMX and no vendor RTOS: the scheduler, the context switcher and the synchronization primitives by hand, against the ARMv7-M architecture and CMSIS register definitions, with 16 KB of SRAM to fit it all in.
Approach
Building it bottom up, one phase at a time, each verified on the board before the next starts. Every line is mine, written from the reference manual and the ARMv7-M architecture manual rather than from a tutorial.
Outcome
The bare-metal foundation runs on the board: my own startup code and linker script, a 1 kHz tick, a rollover-safe delay, and UART and GPIO drivers written at register level.
CARMv7-M assemblyCortex-M4CMSISLinker scriptsGDB
Two stacks, two jobs
PSP · THE RUNNING TASKunused, still paint0xDEADBEEFLOCALS, THEN R4-R11pushed by PendSVR0-R3 R12 LR PC xPSRstacked by the hardwareMSP · THE KERNELunusedHANDLERS, THEN IDLESysTick, PendSV, HardFaultKERNEL FRAMEnever mixed with a taskA task that overruns its paint cannot walk onto the handler that will catch it.

Tasks run on PSP, the kernel on MSP. A task that overruns its stack can't walk onto the handler that will catch it, which is why the two stacks exist at all.

The foundation, a fault that wasn't real, and the kernel it builds towardOpenClose
Context switch
01SYSTICKsets PendSV02EXC ENTRYHW stacks 803PENDSVpushes r4-r1104SP SWAPPSP to next task05EXC RETURNHW pops 8SAVED CONTEXT, PER TASKR4 R5 R6 R7 R8 R9 R10 R11pushed by hand, in PendSVR0 R1 R2 R3 R12 LR PC xPSRstacked by the hardwareSTACK POINTERSPSP · the task that is runningMSP · the kernel and its handlers16 registers saved, 8 of them by hand

Sixteen registers change hands, and eight of them are the switcher's job. Everything else in the kernel is arranged around getting these five steps right.

Starting from the reset vector

Without CubeMX, nothing runs before main unless I write it. I wrote startup.s by hand in ARM assembly. It holds the vector table, and its reset handler copies .data from flash to RAM, zeroes .bss, then calls main. The linker script places each section in memory and exports the symbols the startup code uses to find them.

The vector table is 98 words, and it checks its own length at assembly time. That check exists because of ST: the reference manual's vector table for this part skips position 66, so copying it row by row gives a 97-word table where every interrupt after 65 is shifted by one. It builds and boots fine, and only breaks once you enable an interrupt past that point and the wrong handler runs.

To prove the startup code works rather than assume it, I put a known value in .data (0xDEADBEEF) and one in .bss, then read both back at a breakpoint on main. I also found 0xDEADBEEF in the binary at the address the linker said it would be.

A fault decode that was consistent, and wrong

A printf call hard-faulted, and I decoded it end to end. HFSR said forced, CFSR said a precise BusFault with a valid fault address, and the stacked PC landed inside iprintf, loading through _impure_ptr. That pointer read back as garbage, and the fault address was exactly that garbage plus 8. Everything agreed.

None of it was real. The board was still running the previous binary, and I was decoding it against the new ELF. After a clean flash, compare-sections matched and the fault never came back. The first clue was a watchpoint on _impure_ptr that never tripped, and GDB had been warning me the whole time: "Source file is more recent than executable."

The rule I took from it: check that flash matches the ELF before decoding anything. A decode against the wrong image isn't weak evidence. It's fabricated evidence, and it's convincing because it's internally consistent.

The tick and the delay

SysTick runs at 1 kHz at the lowest priority, because a tick handler should never delay a device interrupt. Getting an exact 1 kHz meant knowing which clock SysTick actually sees and setting the reload value from that.

delay_ticks() measures elapsed ticks by unsigned subtraction instead of comparing against a computed deadline, so it survives the counter wrapping. I tested this by forcing the counter to 0xFFFFFFF0 just before a wait: it passed through zero and still waited the full length. If SysTick isn't running, it traps on a breakpoint instead of returning early, so a hang shows up as a hang and not a subtle timing bug. Public timing APIs take ticks, not milliseconds, the same approach as pdMS_TO_TICKS, so the quantisation stays visible to the caller.

The scheduler it builds toward

Task control blocks, per-priority ready lists, blocked and delayed states with tick-based wakeup, and a lowest-priority idle task so the core always has something valid to run.

The context switch comes first. On exception entry the hardware stacks eight registers, and the PendSV handler stacks the other eight, r4 to r11, in assembly. Each task's initial stack frame is built to look exactly like a preempted task, so the first launch is just a resume. That's one code path instead of a special case that only runs once.

Task states
READYin a priority listRUNNINGhighest ready priorityBLOCKEDon a semaphore or mutexDELAYEDuntil a tick deadlinecreatedscheduledpreemptedtake, unavailablesleep(ticks)given, by another task or an ISRtick deadline reached

Nothing returns straight to the core. A task that unblocks still has to out-rank whatever is running, and the idle task at the bottom priority is what makes the ready set impossible to empty.

Synchronization, and the bug it has to show

Counting semaphores, and mutexes with priority inheritance. The plan is to reproduce unbounded priority inversion first, with three instrumented tasks on a scope, and only then fix it, so the fix is demonstrated rather than claimed.

The three-task case, both ways
WITHOUT INHERITANCEH READYM READYH · HIGHM · MEDL · LOWHOLDS MUTEXBLOCKED ON THE MUTEXRUNSRUNSMUTEX HELDH waits for M to finish, and M never touched the mutex. Nothing bounds that wait but M.WITH PRIORITY INHERITANCEH · HIGHM · MEDL · LOWHOLDS MUTEX, AT HBLOCKEDREADY, NOT RUNNINGRUNSRUNSMUTEX HELDL runs at H's priority while it holds the mutex, so M cannot preempt it and H waits one critical section.inside the critical sectionon the coreready or blocked, not running

The failure is not the high task losing to the medium one on priority, which is what priorities are for. It is the high task losing to a task that never touched the mutex, for as long as that task feels like running.

Catching overflow with paint

With 16 KB of SRAM, there's no room to be careless with stacks. Each task stack is filled with a known pattern at creation. Scanning from the bottom, the first word that no longer holds the pattern marks the deepest the stack has ever reached, which gives that task's high-water mark. If the pattern at the very bottom is gone, the stack has overflowed, and the kernel reports it as a named fault for that task, instead of a HardFault whose stacked PC points at whichever task ran next.

One task stack, painted
unused, still 0xDEADBEEFHIGH-WATER MARKfirst word that is still paintUSED, THIS TASKlocals, then the stacked frameTCB + EXCEPTION FRAMER0-R3 R12 LR PC xPSRPSP GROWS DOWN16 KB of SRAM on the F302R8. Overflow is a paint patternoverwritten past the TCB, not a crash that happens later.

Overflow is a paint pattern that has been overwritten past the TCB. Catching it here is the difference between a named fault and a HardFault whose stacked PC points at whoever ran next.

Working from the silicon, not a tutorial

Register-level work on this part means reading the right table. The F3 puts GPIO on AHB rather than APB2, its USART uses ISR with separate TDR and RDR registers, and its baud register holds the divider directly instead of the mantissa and fraction most tutorials shift into place. Nearly every STM32 example online targets the F1 or F4, so the habit that matters is reading registers back in GDB after configuring a peripheral, because the compiler can't tell which register I meant.

What I've learned so far

Writing GPIO and UART drivers from scratch without a HAL, and learning SPI and I2C alongside UART, so I understand how the three serial protocols differ instead of only the one I needed first.

Writing my own startup code in assembly and my own linker script: what the vector table is and how the core uses it on reset, what sections are, how .data and .bss get set up before main, and how to read and write a linker script.

How the chip is clocked: the internal RC oscillator versus an external quartz crystal and why you'd pick one over the other, how the PLL and the system clock mux choose and multiply a source, and how the AHB and APB prescalers divide it down for each bus. That's what turns a clock frequency into ticks and real time, and it's why a peripheral's timing depends on which bus clock it sits on. Some peripherals run off those prescaled bus clocks and some don't, so I learned to check which clock a peripheral actually uses before computing anything from it.

On this board that means the internal 8 MHz HSI with no PLL yet, so SYSCLK and the APB1 clock PCLK1 are both 8 MHz, and USART2, which this part wires straight to PCLK1, takes its baud divider from it: 8 MHz/115200≈698\,\text{MHz} / 115200 \approx 69. The PLL comes later, and the limits are already worked out: from HSI it tops out at 64 MHz, because 72 MHz needs an external crystal the Nucleo doesn't fit, and APB1 is capped at 36 MHz, so it has to run at half of SYSCLK, 32 MHz, and every divider derived from PCLK1 moves with it. The clock tree is derived from three numbers in one header, so that change is a two-number edit.

Reading the reference manual and the core documentation until a peripheral makes sense, debugging at register level, and how PSP and MSP split the stack between tasks and the kernel. Exception handling is what I'm working on now.

04UBC APSC 101 · team design project

A sonar-guided claw that stopped acting on noise

A first-year team design project: a servo claw that finds an object by ultrasonic distance and picks it up on its own. Raw sonar drops to zero at random, and a state machine reading those samples directly opens and closes the claw on readings that were never there. The fix was a filter in front of the decision, not another threshold behind it.

Role
Firmware dev, on a first-year course project
Stack
Embedded C++, Arduino, DSP filtering
Timeline
Jan 2026
Outcome

False state transitions from sonar dropouts stopped, and every threshold moved into one header so teammates who were not writing firmware could retune the machine without reading it.

Embedded C++ArduinoIIR filteringState machines
The distances the machine is built on
CHASSISArduinosonarclaw open30 cm · ENGAGE60 cm · RELEASEobjectA dropout reads as 0 cm, which is inside the engage arc.That is why the filter sits in front of the decision rather than behind it.

Engage at 30 cm, release at 60 cm, and a dropout that looks like 0 cm. The hysteresis is the gap between the two arcs; the filter exists because zero is inside the first of them.

The machine, the filter maths and the module splitOpenClose
State machine
S0SEARCHINGclaw openS1ENGAGINGclosingS2LOCKEDcarryingS3DISENGAGINGopening≤30 cmclosed≥60 cmreleased, search againevery transition reads the filtered distance, never the raw sample

Four states, three thresholds and one return edge. The interesting part is not the machine, it is what the machine is allowed to read.

The machine

Four states on distance thresholds: searching with the claw open, engaging when the object comes inside 30 cm, locked while it is carried, and disengaging back to searching once the reading clears 60 cm at the drop zone. The gap between the two thresholds is the hysteresis that keeps a borderline object from being picked up and released on alternate samples.

Filtering the input, not patching the output

A first-order Butterworth low-pass in Direct Form I, y[n]=b0x[n]+b1x[n−1]−a1y[n−1]y[n] = b_0 x[n] + b_1 x[n-1] - a_1 y[n-1], cutting at 1 Hz against a 20 Hz sample rate. Separate feedforward and feedback delay lines, and the last valid output is held when a reading comes back invalid, so a dropout costs a sample of freshness instead of a spurious transition. Butterworth for the maximally flat passband: the point is to remove the spikes without dragging the legitimate approach.

What the thresholds see
60 cm30 cm0 cmthe crossing that was realfour dropouts, each reading zero centimetres, each one inside the engage thresholdraw sonarfiltered, and what the machine readsfirst-order Butterworth, cutting at 1 Hz against a 20 Hz sample rate

Four dropouts, and four transitions a machine reading raw samples would have made on the way in. The filter sits in front of the decision rather than behind it, which is why there is one crossing to act on instead of five.

Written to be retuned by other people

config, sonar, filter and claw split into separate modules, with every pin, threshold and filter coefficient in one header. Teammates who were not writing the firmware could change how the machine behaved and test it, which on a first-year team is the difference between one person owning the code and five people making progress.

Four files, one header
CONFIG.Hpins, thresholds, coefficientsSONARdistance inFILTERlast valid outCLAWthe machineChange a threshold in one header. The three modules pick it up without being edited.

A threshold change is an edit in one header. The three modules pick it up without being opened.

05UBC Formula Electric · In progress

An active differential for when the IMU drops out

Role
Firmware developer, vehicle controls team
Stack
C++, vehicle dynamics, state estimation, GoogleTest
Timeline
Nov 2025; Jun 2026 to present
Problem
The team's torque vectoring closes a loop on yaw rate from the IMU. When the IMU fails, is still coming up, or reads something nobody trusts, that controller can't be trusted to make a yaw moment, and the car still has to be driveable.
Goal
A fallback that keeps the car driveable and traction-limited with no IMU signal at all. It acts as an active differential, not a torque-vectoring controller: it never closes a yaw-rate loop.
Approach
Started from Draou's 2013 electronic differential for two in-wheel motors and reworked it for a four-wheel, torque-controlled car cornering past 1.5 g. Wheel-speed targets become ceilings instead of setpoints, speed comes from GNSS and wheel odometry instead of the IMU, and the module steps down through four levels as sensors drop out.
Outcome
The research and design are done, with a proof of concept passing 23 of 23 host tests. It is with my team lead for review before I write it into the vehicle firmware.
Vehicle DynamicsState EstimationC++GoogleTestProof of Concept
Cornering kinematics
shorter arclonger arcv innerv outerδsteering anglevxalong the headingr, from steeringMEASUREDδ, wheel speeds, motor torques, GNSS speedNOT FORMEDlateral velocity vy, slip angle α

Four wheel speeds and vxv_x along the heading. The inside pair is slower because it is on a shorter arc, which is the whole job of the differential. Yaw rate is drawn because it is used, but as a reference worked out from steering, never as a measurement.

The four levels, the maths behind them and where it standsOpenClose
Four levels, one for each sensor that's left

The module takes the IMU's health from the main torque-vectoring checks and picks a level every cycle. INACTIVE while the IMU is healthy, still computing its torque in the background so the handover is bumpless. ACTIVE_DIFF_GNSS when the IMU is lost but GNSS is good. ACTIVE_DIFF_WHEEL when GNSS is gone too. OPEN_DIFF, equal torque on all four wheels, when steering or more than one wheel speed is lost.

Moving between levels is deliberately slow. A downgrade waits 50 ms, so one bad sample does nothing. An upgrade waits a full second and only happens near straight, never mid-corner. Any downgrade then holds until the car stops, so the driver never gets a car that changes behaviour lap to lap, and every switch crossfades the torque over 0.25 s.

From a two-motor paper to a four-wheel car

Draou's electronic differential gives two in-wheel motors speed setpoints from Ackermann geometry, Δω=dwtan⁡δLw ωv\Delta\omega = \frac{d_w \tan\delta}{L_w}\,\omega_v, and assumes low speed with no tyre slip. Our car is torque-controlled and corners past 1.5 g, so I kept the kinematics and changed their role.

The wheel-speed targets become ceilings, not setpoints. Torque comes from the driver's request split by grip, and a wheel only loses torque if it spins past its ceiling, which is why no yaw feedback is needed. All four targets come from one pair, the speed vv and a reference yaw rate rrefr_{\text{ref}} at the rear-axle centre, so they always agree with each other. The rear equations are exact whatever the car is doing laterally, because the rear wheels aren't steered.

The reference yaw rate adds what the paper leaves out: an understeer gradient KK, and a limit at what the tyres can actually hold. rref=clamp⁡(vtan⁡δeffL+Kv2, ±kμμgmax⁡(∣v∣,1))r_{\text{ref}} = \operatorname{clamp}\left(\frac{v\tan\delta_{\text{eff}}}{L + K v^2},\ \pm\frac{k_\mu \mu g}{\max(|v|, 1)}\right), where δeff\delta_{\text{eff}} is a load-weighted average of the two front wheels. The car runs anti-Ackermann, and under load transfer the loaded outer tyre does most of the work, so it gets most of the say.

Knowing the speed without an IMU

All four wheels are driven, so every wheel turns slightly faster than the ground and none of them is a free-rolling speed reference. Speed comes from a small predict-correct filter instead. It predicts from the motor torques and a drag and rolling-resistance model, then corrects with GNSS and a weighted least-squares fit of the four slip-corrected wheel speeds.

The GNSS correction uses the receiver's raw velocity, not the INS solution, because that solution leans on the IMU this module exists to do without. Without GNSS, traction-control logic bounds the estimate: while accelerating the car can't be faster than its slowest wheel, and while braking it can't be slower than its fastest.

A loaded tyre is about 3% smaller than its nominal radius, which is as big as the whole slip budget. So each wheel's rolling radius is learned from GNSS whenever the car is coasting in a straight line.

Splitting torque and catching wheelspin

Torque is split in proportion to each wheel's load, which keeps every tyre at the same fraction of its grip. That puts more torque on the outer wheels and makes a turn-in yaw moment, and with no IMU nothing supervises it, so the moment is capped: 300 N·m with GNSS, and zero without, where left and right get equal torque.

A PI controller per wheel cuts torque only from a wheel that crosses its ceiling, and gives it back as grip returns. The cut isn't moved to the other wheels, trading some acceleration for predictability. A wheel with no speed feedback can't be slip-limited at all, so it and its axle partner go to zero, and losing one corner can't turn into an uncommanded yaw moment.

What it can't know

Lateral tyre modelling isn't attempted. It needs the body slip angle, which can't be observed without an IMU: a single-antenna GNSS gives the direction the car is travelling, not the direction it is pointing. Everything above is built from quantities that survive the IMU going away.

The quantity this module does without
where the wheel pointsvwhere it is actually goingαlateral force follows αα = atan(lateral velocity / forward velocity)THIS TIER READSWHEEL SPEED AND TORQUEalready on the busSTEERING AND GNSS SPEEDalready on the busAND NEVER FORMSSLIP ANGLE αneeds lateral state, so an IMUnothing here to lose when the IMU is absent

Slip angle is what the main controller can use and what this one refuses to depend on. Nothing in it is built out of lateral state, so nothing is lost when the IMU is.

The IMU the main controller needs

The main controller does want lateral state, so I also brought up the car's SBG Ellipse-D GNSS/INS and verified its CAN output end to end, confirming the same messages reach telemetry logging and the downstream vehicle-dynamics control inputs. A sensor that logs but does not reach the controller is the failure this was checking for. It is also where this module's GNSS velocity comes from.

One bus, two consumers
01SBG ELLIPSE-DGNSS/INS02CAN BUSthe same framesTELEMETRY LOGthe frames that were sentDYNAMICS INPUTSthe frames the loop readsA sensor that logs and never reaches the controller is the failure this was checking for.

The check is the fork, not the part. If only the log sees the frames, the loop is still blind.

Where it stands

The research and design are complete, and the proof of concept passes 23 of 23 host tests in GoogleTest. The kinematics match rigid-body rotation to 10−410^{-4} m/s. A wheel spinning 25% fast is cut to under 10% of its torque while the others are untouched. The handover from the main controller never steps more than 0.34 N·m in a cycle. With no GNSS and a slip model 50% wrong, the speed estimate stays within 4.3%.

Those tests run on synthetic kinematic data on the host, not on a vehicle model or the car's microcontroller. Next is my team lead's review, then the firmware implementation, closed-loop simulation and calibration: the steering lookup table, the understeer gradient, μ\mu and the slip-limiter gains.

Stack

What I reach for

These are the few domains I have built in, and the tools I reach for when I do.

Languages

  • C
  • C++
  • ARMv7-M assembly
  • Python
  • MATLAB
  • Java
  • SQL

Targets & runtime

  • STM32H7
  • STM32H5
  • STM32F3
  • Cortex-M4 / M7
  • FreeRTOS
  • CMSIS
  • Arduino

Signals & control

  • CAN / CAN FD
  • PID control
  • IIR filtering
  • Butterworth
  • Biquad DF1
  • GNSS / INS
  • Simulink

Test & tooling

  • SIL / PIL
  • GoogleTest
  • Vector CANoe
  • J-Link
  • OpenOCD / GDB
  • CMake
  • Make
  • Git
  • CI/CD
Available for firmware & controls developer roles · Vancouver, North America or Europe

Got something that has to run on time?

Firmware, controls or signal conditioning: if it has a deadline measured in milliseconds, I would like to hear about it!