Barbell Velocity Tracker

Skills/Software/Platforms: C++ · Zephyr RTOS · nRF52840 · LSM6DSOX · Bluetooth LE · Extended Kalman Filter · Eigen · nRF PPI


What this is

I will be frank, I’m trying to recreate the Eleiko bar sensor kit, for less than their £830 price tag (which is kind of obscene) for what essentially is two IMUs bolted to a bar connected via Bluetooth. I haven’t been able to get my hands on one of them as much as I would love to do a tear down and figure out what they’ve got inside, I have a pretty good idea of what’s likely inside.

They are a velocity based tracker, they have no external references for position or speed, so it’s got to be an IMU inside the mechanism.

The concept: one sensor unit on each end of the bar. They measure acceleration and angular velocity independently, communicate with each other over Bluetooth, and an extended Kalman filter fuses both datasets while enforcing the constraint that the bar is a rigid body: meaning both endpoints must agree on the motion (I’m going to ignore the times where the bar has a bit of whip for the time being.


System architecture

System architecture diagram


Hardware platform: nRF52840 dev kits + LSM6DSOX

Each end of the bar carries an nRF52840 development kit with an LSM6DSOX inertial measurement unit connected over SPI.

The LSM6DSOX is a six-axis IMU (three-axis accelerometer, three-axis gyroscope) from STMicroelectronics. It was chosen for a combination of reasons: the FIFO buffer means bursts of IMU data can be collected without demanding constant CPU attention, the output data rate goes up to 6.67 kHz which is comfortably beyond what the Kalman filter needs, and the sensor includes a machine learning core that is not used here but is available for future gesture-based rep detection if I felt spicy and wanted to play around with that. The sensor is well supported by Zephyr’s driver subsystem which avoids having to write raw SPI drivers from scratch which I don’t fancy doing for a project like this if I’m not getting paid for it(!).

It gives me a ‘significant motion detection’ which would be either the bar being unracked, or lifted off the floor, which would help to bring the board out of deep sleep mode.

I am using the nRF52840 dev kit, as eventually I’d want to spin up a PCB and mechanical housing for the sensor kit and put it on the end of the barbell. The benefit of the dev kit is that I get two nRF52s, and I can get to work writing the code and validating the Bluetooth connectivity between the devkits without having to worry about my board being wrong.

Dev kit and IMU on desk

nRF52840 dev kit and IMU on bar


Why the nRF52840

I hadn’t used the nRF52 series before outside of interacting with products that have them embedded, so it was going to be fun getting familiar with this family of micros. The main determining factors of using the nRF52 were dictated by the fact that this will eventually be something similar to a consumer device with a decent amount of RAM and compute for the EKF and solid Bluetooth connection with very good low energy consumption for battery life (potentially with some of the PMIC offerings from Nordic too).

Bluetooth is the primary communication channel between the two ends of the bar. The nRF52840 has a mature, well-documented SoftDevice (S140) and Zephyr has first-class BLE support for it via the Bluetooth Host stack. More importantly, Nordic exposes the radio event notification mechanism for me, so I can detect exactly when a packet is received by the core at the hardware level, with a timestamp the CPU can capture. This makes the synchronisation of the two sensors so much easier as I’d ultimately be implementing something like this from scratch if I was using a different system.

RAM is the second reason. For the EFK I’m using the Eigen linalg library for the matrix calculations. I’m already going to be running Bluetooth, Zephyr, IMU driver and EKF. I don’t want to start running out of RAM and 256KB is a nice beefy amount that should give me plenty of wiggle room for trying to cram everything into a single micro, but I might still keep the option open to add more down the line.

PPI The Programmable Peripheral Interconnect is the third reason, and the most interesting as it’s a neat feature I’m going to use for this project as I haven’t used it before. It is covered in detail in the time synchronisation section below.

Zephyr RTOS support for nRF52840 is excellent. Nordic themselves are significant contributors to Zephyr and the board support package for the nRF52840 DK is upstream and well maintained.


The fundamental challenge of a two-sensor system is that the two microcontrollers do not share a clock. Each runs its own oscillator, and even if they were started simultaneously, they would drift immediately. IMU measurements from the two ends of the bar carry an implicit timestamp based on the local clock so if I don’t reconcile those timestamps, the fusion step is comparing data from slightly different moments in time, which introduces phantom velocity into the estimate.

The PPI solution

The nRF52840’s PPI (Programmable Peripheral Interconnect) is a way to connect hardware events to tasks in digital logic as opposed to compute cycles (I think that’s how they do it at least). Radio events are PPI event sources: when the radio core asserts the END event (packet fully received), PPI can automatically capture the current value of a hardware timer into a CC (capture/compare) register in the same clock cycle. The CPU then reads that register to find out exactly when the packet arrived.

The synchronisation scheme works as follows:

  1. The follower sends a timestamped packet containing its local clock value at the moment of transmission.
  2. The leader’s PPI captures the END event timestamp into a timer CC register in hardware: no interrupt latency, no scheduler jitter.
  3. The leader subtracts the follower’s reported transmit time from its own captured receive time to get the one-way flight time (roughly half the round trip).
  4. The accumulated clock offset between the two nodes is maintained as a running estimate that feeds a correction term into the EKF’s observation model.

This is one of the great benefits of the PPI from the nRF52840 as I can use this isntead of running a radio interrupt handler, which would be costly in compute.


Rigid body assumption and dual-IMU fusion

A barbell is if you make some simplifications, a rigid body. If you know the acceleration and angular velocity at one end, and you know the bar’s geometry (length, centre of mass), you can predict what the sensor at the other end should be measuring. If the two sensors disagree beyond measurement noise, something interesting is happening (flex in the bar, asymmetric loading, a missed rep).

The rigid body constraint enters the EKF as a pseudo-measurement. The state vector includes position and velocity in 3D plus orientation (represented as a quaternion to avoid gimbal lock), and the constraint says the endpoints must be consistent with a single rigid transformation. Residuals that violate this constraint are down-weighted. In practice this means the filter is more robust to sensor bias on one end: if one IMU accumulates drift faster, the other end’s data pulls the estimate back toward consistency.


Pre-filtering: moving average before EKF update

Raw IMU data is not fed directly into the EKF update step. Each sensor’s accelerometer and gyroscope output is first passed through a short moving average filter before being used as an EKF observation. The reason for this is specifically about the pre-fit residual (the difference between the predicted observation and the actual observation, computed before the Kalman gain is applied). If a bar is dropped (especially from overhead), the impact spike can produce an acceleration reading that saturates the sensor (the LSM6DSOX’s full-scale range is configurable up to ±16 g) and, if it were fed raw into the EKF, would drive the pre-fit residual to a very large value. A large pre-fit residual causes the Kalman gain to allocate substantial weight, pulling the state estimate way off course. The moving average acts as a low-pass pre-conditioner, with the window length being how I’ll tune the threshold to avoid clipping real movements.


Drift correction: zero-velocity updates (ZUPTs)

Integrating acceleration to get velocity accumulates error over time. Gyroscope bias and accelerometer noise both contribute, and without correction the velocity estimate drifts even when the bar is stationary.

The correction mechanism is a zero-velocity update. Between repetitions, the bar rests on pins or on the floor: it is not moving. The system detects this condition by monitoring the magnitude of the accelerometer output (approximately 1 g, pointing down, with low variance when stationary) and the gyroscope output (approximately zero, with low variance). When both sensors agree the bar is stationary for a configurable dwell time, the EKF is given a pseudo-measurement asserting velocity = 0 with high confidence.

This drives the Kalman gain to correct any accumulated velocity drift back toward zero, and it also lets the covariance on the velocity states reset. Effectively, each rest between reps is a re-initialisation opportunity. The quality of velocity estimates within a rep depends only on how much drift accumulates during that rep, not on the entire session’s worth of accumulated error.

The ZUPT detection threshold is a tuning parameter. Setting it too sensitive causes false positives during a slow eccentric phase; too loose and genuine rest periods are missed, especially with a bar that bounces slightly on the floor after a deadlift.

The ZUPT design here was directly inspired by Fixit-Davide/imu_zupt, a ROS 2 C++ implementation of ZUPT for inertial navigation. Three things from that project fed directly into this one: the idea of requiring a sustained time window of stationary conditions before committing to a zero-velocity update (rather than acting on a single below-threshold sample); the pattern of overriding the filter’s observation noise during the stationary period to force a strong correction (mapped here to a very small R_ZUPT = 1×10⁻⁴); and the principle of using the stationary window to correct orientation drift: in imu_zupt this targets yaw error from Earth’s rotation, and in this project it becomes the gravity-alignment pseudo-measurement that corrects roll and pitch between reps. The specific detection criteria (variance of |a| − g and |ω| rather than a fixed threshold on individual samples) and the dwell counter requiring ZUPT_DWELL consecutive detections before the update fires are adaptations for the barbell use case, where a dropped bar bouncing on the floor briefly mimics a genuine rest period if the detector responds to a single quiet sample.


What still needs doing

This project is in the design and early prototyping stage. The core algorithm is designed and the platform choices are made; the remaining work is:

  • Implement SPI driver configuration and LSM6DSOX FIFO readout in Zephyr
  • Implement BLE link between leader and follower nodes
  • PPI timer capture for radio sync: validate against a logic analyser
  • Implement and tune EKF on desktop with logged IMU data before deploying to hardware
  • Mechanical enclosure for foam isolation
  • Validate against a known velocity reference (high-speed camera, or string-pot encoder)
  • Eventually: design a custom PCB small enough to mount on a standard 50 mm Olympic bar collar

Big Revision 2 - June 2026:

What follows are things that were not in the original design: they were each discovered to be necessary once earlier layers were working and the gaps became visible.


IIR Butterworth pre-filter (alternative to the moving average)

The moving average is fine as a first-pass anti-alias filter, but once I had velocity data flowing I noticed the peak velocity readings were slightly smeared in time. A moving average is a linear-phase FIR filter: it delays all frequencies by the same amount, which is good for phase integrity, but that delay is non-trivially large at the frequencies where a barbell lift lives (0.3–3 Hz). A sharp enough window to reject IMU vibration noise also introduces enough group delay to blur the peak-velocity timestamp.

The fix was a 2nd-order Butterworth IIR filter implemented with the bilinear transform and frequency pre-warping at 15 Hz. The Direct Form II transposed structure was chosen for its better numerical properties at the low coefficient values this cutoff produces. It lives in common/iir_filter.c and is selectable over the moving average at build time with CONFIG_BV_PREFILTER_IIR=y. The moving average remains the default because it is simpler to reason about and the IIR filter needs independent tuning; the option being there means I can A/B test both on the same logged dataset using the Python sim.


IMU calibration system: factory 6-position and at-boot drift check

I naively assumed the EKF’s accelerometer and gyroscope bias states would absorb the IMU’s factory tolerance errors. They do, but too slowly to be useful at the start of a lift. The bias states converge through the Kalman update only when the rigid-body constraint produces a meaningful innovation: in other words, only while the bar is moving. The first few seconds of a set, which are the most diagnostically interesting for a fresh-off-the-rack rep, had noticeably bad velocity estimates while the bias states were still converging.

The right fix was to just calibrate the sensors properly. The 6-position calibration (±X, ±Y, ±Z face up, 500 samples per position) computes per-axis scale and bias:

scale_i = (mean_pos_i - mean_neg_i) / (2 × 9.81)
bias_i  = (mean_pos_i + mean_neg_i) / 2

These values, plus the gyroscope bias from a 60-second static collection, are written to NVS flash on the nRF52840 with a CRC check. The correction is applied in imu_thread_fn before any sample reaches the EKF, so the filter starts from a clean baseline.

At every power-on, cal_init() collects 1 second of still samples and compares the gyro bias to the stored value. A drift of less than 0.3 deg/s is applied silently (normal temperature variation); 0.3–2.0 deg/s triggers a warning; above 2.0 deg/s the stored value is used as a fallback and an error is logged. The EKF thread waits on a semaphore (g_cal_ready) that is not given until the boot calibration completes, so the filter never initialises against an unsettled IMU.

tools/calibration_tool.py runs the guided collection procedure interactively over USB serial. It saves a JSON record of the calibration to tools/logs/ for reference and prints the corrected axis magnitudes so you can verify the result before flashing the values as firmware defaults.


Shadow log for offline EKF tuning

Reflashing firmware to test an EKF parameter change takes a couple of minutes. Doing that for every value of Q_vel or ZUPT_DWELL was painful. What I wanted was to capture raw sensor data once, then replay it through the algorithm as many times as needed with different parameters.

The peripheral can optionally stream its raw filtered IMU data at 100 Hz over its own USB serial port (CONFIG_BV_SHADOW_LOG=y). This is the “shadow log”: a verbatim record of what the peripheral is handing to the BLE transmit thread. tools/sim/ekf_sim.py mirrors the embedded EKF algorithm in Python (same 16-state quaternion filter, same predict and update steps) and accepts a shadow log csv as input. Parameter changes are a single number change in Python, and the result is immediately visible in the plot.

tools/sim/test_ekf.py runs the Python EKF through a set of unit tests (constant-velocity straight-line motion, stationary convergence, ZUPT reset) that serve both as correctness checks for the Python sim and as a specification that the embedded implementation must match.


Gravity alignment during ZUPT

Without a magnetometer, quaternion yaw is unobservable. Roll and pitch are in principle observable through the accelerometer, but only when the accelerometer reading is a reliable gravity reference: which is only reliably true when the bar is stationary. Without a correction, roll and pitch drift accumulates across reps; over a 10-rep set this is visible as a growing offset in the horizontal velocity components.

ZUPT windows are the natural place to anchor the orientation back to gravity. When ZUPT is active, ekf_update_gravity_align(accel_body) injects a pseudo-measurement that drives the quaternion’s roll and pitch toward the gravity direction. Yaw is left free (no heading reference). The effect is that each rest between reps resets roll and pitch accumulation; the velocity estimate for the next rep starts from a clean orientation rather than one drifted from the previous rep.


Madgwick AHRS parallel baseline

Early in EKF tuning, the quaternion was occasionally diverging and it was hard to tell whether it was a Q_quat / Q_gbias problem or something wrong with the rigid-body constraint. The EKF has many interacting parameters and when it goes wrong it’s not easy to figure out why.

Running a simpler, independent attitude estimator in parallel gave an immediate diagnostic: if the Madgwick quaternion and the EKF quaternion agree, the EKF orientation estimate is probably fine but if they diverge significantly, the EKF is pulling in a wrong direction.


String-pot validation

I still need to implement this, and in order to do it I need to either buy (I would like to not spend money), or borrow a strong-pot from a friend. I need some ground truth to be able to validate my EKF against and this is the best I can think of. I’m going to plug the string pot into an ESP32 for data collection and pipe that in to compare against the EKF with some of the tools that I wrote.

The string-pot setup gives a true ground truth velocity value. An ESP32 samples the potentiometer ADC at 1 kHz and streams time_us,raw_adc over USB serial. tools/string_pot_logger.py converts the ADC readings to displacement in mm and differentiates with a butterworth filter to get velocity, saving a csv that can be directly overlaid on the EKF output. The truth tool program loads both csvs, aligns them in time (interpolating truth onto the EKF time axis with a configurable sync offset), and computes RMSE, bias, and 95th-percentile error


ZUPT threshold tuner

ZUPT detection has two thresholds that interact: the accelerometer variance threshold (ZUPT_ACCEL_THRESH) and the gyroscope variance threshold (ZUPT_GYRO_THRESH). Set either too tight and ZUPT fires during a slow eccentric or a bar that bounces on the floor; set too loose and genuine rest periods are missed, and drift accumulates through the whole set.

The thresholds need to be tuned on real data for the specific movement (squat, snatch, C&J), because each will have a different bar-velocity profile. tools/sim/zupt_tuner.py sweeps a grid of (accel_thresh, gyro_thresh) combinations over a shadow log csv, counting ZUPT trigger events at each combination, and renders a heatmap. The useful operating point is in the flat region where the trigger count is stable: tight enough to fire reliably in rest but not so tight that the count increases with small parameter changes, indicating the boundary region.


Desktop tooling suite

As the project grew, the manual workflow (capture data, import to Jupyter, plot manually) was infuriating and slowing me down, so I spun up a set of purpose-built tools:

ToolPurpose
serial_logger.pyCaptures ASCII csv + demuxes binary diagnostic frames to file
plot_data.pyPost-hoc visualisation: IMU shadow log, velocity csv, or full EKF state log
live_plot.pyReal-time animated three-panel display at 20 Hz via FuncAnimation
truth_compare.pyEKF vs string-pot overlay with RMSE / bias metrics and parameter suggestions
calibration_tool.pyGuided 6-position accelerometer + 60 s gyro calibration over USB serial
string_pot_logger.pyCaptures string-pot ADC data from ESP32, converts to filtered velocity csv
sim/ekf_sim.pyPython mirror of the embedded EKF for parameter tuning on logged data
sim/zupt_tuner.pyZUPT threshold grid sweep with heatmap output
sim/test_ekf.pyUnit tests validating the Python EKF against known trajectories

Things still outstanding:

  • String-pot ground truth validation: still need to get hold of a string-pot (buy or borrow), wire it to the ESP32, and run a real comparison against the EKF output rather than just against itself
  • Foam-isolated mechanical enclosure for the sensor units: nothing built yet, it’s still just the dev kits clamped to the bar
  • Validate the PPI-based time sync against a logic analyser instead of just trusting the code path
  • Try code on custom PCB as the schematic is being built up from the PCA10056 reference and layout still work in progress
  • Rep detection? - some kind of thresholding of velocity? Or do this on the app manually by showing the plots?
  • Make an interface app on mobile (I am not a mobile dev lol))
  • Actual on-bar lifting sessions to see how the ZUPT thresholds and rep detection hold up outside of logged/simulated data