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.
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
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.
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 (, 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.
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.
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 () 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.
The constructors assert on configurations that have no valid answer: a cutoff or sample rate of zero or less, an EMA outside , or a biquad cutoff at or above . At that point the prewarp term blows up and the coefficients stop meaning anything.
That limit is the Nyquist–Shannon sampling theorem. A signal sampled at can only represent frequencies below ; 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.
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.
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.