Making electronic calipers
← Back to Kevin's homepagePublished: 2024 November 2Have you ever wished for a 500 Hz, millimeter-precise linear position sensing system? Well you’re in luck — all you need is some circuit board, a basic microcontroller, and a wee bit of maths!
See also the full source code and my research log for this project.
Why make calipers?
Electronic calipers are awesome. This \$30 pair has served me well for years, reading far more precision than my skills justify:
Such calipers work via capacitive coupling between a PCB on the powered slidey display and a passive PCB “scale” in the stationary spine.
Back in March, I idly wondered if the same working principle could be used for a cheap and cheerful “maker-friendly” positioning system. E.g., slide some passive PCB scales into an aluminum extrusion rail, add a capacitive pickup to the bottom of whatever carriage you’ve got riding along, and tada — you’ve got sub-mm closed-loop positioning. All for the cost of some PCB, a few GPIO pins, and some firmware (so free, basically).
I figured someone else must’ve done this before, but I wasn’t able to find any “open source caliper” projects. The closest was this hackaday.io project page, started literally a month before.
I reached out to the author, Mitko, and offered to implement the firmware if they sent me a PCB. My main motivation was to learn some digital signal processing, as I’d never studied it beyond a passing undergrad mention of the Fourier transform.
If you just want some off-the-shelf precision measurements and don’t want to go on An Adventure, you may want to consider instead:
- reading measurement data directly out of cheap calipers via their secret data interface
- searching for “digital read out” (DRO) kits, which is the generic term for all sorts of capacitive, optical, and magnetic precision linear and angular measurement schemes (typically for retrofitting manual machine equipment with a digital readout, so you can be the “NC” of “CNC”). E.g., this \$200 magnetic encoder with some \$1/cm linear tape.)
Caliper theory
Here’s a photo of calipers disassembled by my collaborator Mitko (annotations mine):
The left half is the caliper’s stationary metal spine, which contains a passive PCB with a pattern of “reflectors” (which look sorta like the capital letter “T” rotated 90 degrees clockwise).
The right half is the powered display part of the calipers, which slides up and down along the metal stem; this PCB has a long receiver pad and a bunch of emitter pads that look like the keys of a piano.
When assembled (folded together like a book), the reflector “T” stems are over top the signal-emitting piano keys and the reflector “T” crossbars are over the receiver pad.
(Pedant note: There’s not actually any “reflection” going on, the plates are capacitively coupled. I just find the term “reflector” conveys the right vibe.)
Here’s a close up, taken from Big Clive’s excellent caliper teardown video (annotations mine):
The top half is the stationary part and the bottom half is slidey part.
Note the following details of the geometry:
- The slidey part emits 8 signals through the little piano keys (labeled), which repeat along the entire length.
- The reflector plate stems are exactly 4 keys wide.
In essence, the reflector plate “adds up” the signals of the piano keys underneath it (in this photo, signals 0, 1, 2, and 3).
Imagine sliding the caliper display 0.5 keys to the right. Then the reflectors would be above half of signal 0, all of signals 1, 2, 3, and half of signal 4. Sliding another 0.5 keys to the right, the reflectors would then be on top of signals 1, 2, 3, and 4.
The reflectors are just passive pieces of metal; all they can do is sum together the signals coupled to them. This summed signal is then reflected back to the slidey part’s single receiver pad.
So what are the 8 signals you should emit?
If you use sine waves at the same frequency but different phases, then their reflected sum will always be a sine wave of the original frequency, with some combined phase and amplitude (proof).
That is: as you move the slidey part, the phase offset of the reflected signal changes.
Since we have 8 signals, if we evenly divide the unit circle so that the nth signal is:
$$\sin\left( 2\pi f + 2\pi\frac{n}{8} \right)$$
then we can track the cumulative phase offset of the received signal (relative to some initial position) and know that every $2\pi$ moved in phase space corresponds to a linear movement 8 emitter keys wide.
Microcontroller implementation
Mitko mailed me version 1.1 of his PCB (schematic), which is built around an stm32f103 microcontroller.
I wrote the firmware using the Embassy Rust framework, which worked reasonably well. (“Well” as far as embedded goes — there was a side quest tracking down an intermittent freeze that locked out the debugger, which seems to be a genuine hardware bug triggered only on older ARM core silicon revisions.)
The firmware needs to:
- emit 8 sinusoidal waves
- measure the reflected sum calculate the phase offset
Let’s take these in turn.
Emitting sinusoidal waves
The stm32f103 doesn’t have a digital to analog converter, but we can emit a sinusoidal wave using “pulse density modulation” (PDM). The technique is similar to PWM (“pulse width modulation”) in that an analog signal level is approximated by having a digital signal stay “on” for the appropriate fraction of time. But while PWM has its “on” fraction all at once, the PDM signal spaces it out across the sample window.
For example, to represent an analog level of 50% using 8 pulses:
PWM: X X X X . . . .
PDM: X . X . X . X .
the PWM signal stays high (x
) for the first half of the period, whereas the PDM signal alternates.
This is preferable for our use case, since it means the switching noise is at a higher frequency, further away from our lower frequency sinusoidal wave.
We need all of the waves to move in lockstep with each other, so rather than updating the pins one-by-one, we update all of them with a single 32-bit write to the GPIO’s “bit set reset register” (BSRR).
Furthermore, since we know how many PDM samples we want in advance, we can take pity on our lil’ stm32f103 (which doesn’t even have a hardware floating point unit) and calculate all of the BSRR values at compile-time.
Rust’s formal compile-time machinery doesn’t support trigonometry, so we use a build.rs
script to generate a string of code at compile-time:
fn generate_pdm_bsrr(n_samples: usize) -> String {
let mut output = String::new();
output.push_str("pub const PDM_SIGNAL: [u32; ");
output.push_str(&n_samples.to_string());
output.push_str("] = [\n");
let n_waves = 8;
let mut errors = vec![0.0; n_waves];
for sample in 0..n_samples {
let mut bsrr = 0u32;
for wave in 0..n_waves {
let phase_offset = 2.0 * PI * (wave as f64) / (n_waves as f64);
let angle = 2.0 * PI * (sample as f64 / n_samples as f64) + phase_offset;
let cosine = angle.cos() as f32;
let normalized_signal = (cosine + 1.0) / 2.0;
if normalized_signal > errors[wave] {
bsrr |= 1 << wave; // set bit
errors[wave] += 1.0 - normalized_signal;
} else {
bsrr |= 1 << (wave + 16); // reset bit
errors[wave] -= normalized_signal;
}
}
output.push_str(&format!(" {:#034b},\n", bsrr));
}
output.push_str("];\n");
output
}
This emitted string is then written to a file, which we import
as usual from our main code namespace.
The PDM_SIGNAL
const slice is then baked into the firmware, and at runtime a hardware timer and a DMA task is used copy each value directly to BSRR at a fixed rate.
This prevents any jitter in the emitted signal, as after starting the transmission the CPU is no longer involved.
Measuring phase offset
The reflected composite wave is measured by the stm32f103’s ADC. A DMA task is triggered at the same time as the emitted PDM signals, and it reads a fixed number of samples into a buffer.
So how do we get the phase offset?
If you’re like me, the first thing you need to do is read some textbooks to figure out what’s what. I recommend Understanding Digital Signal Processing by Richard Lyons, as the book has a casual friendly style and is clearly written by an experienced engineer — the final chapter is simply 150 pages of “Digital Signal Processing Tricks”!
Anyway, we know from earlier that our reflected signal $s(t)$ is the sum of the sinusoidal signals that we emitted, so it must also be a sinusoid with some phase offset; let’s call it $A \cos( \omega t + \phi)$ (with $A$ some constant representing a change in amplitude due to our capacitive coupling, amplification, etc. compared to our original emitted signal).
Then as I’m sure you recall from trigonometric addition formula from grade school, we can rewrite this as:
$$ \begin{align} s(t) &= A \cos( \omega t + \phi)\newline &= A \left[ \cos( \omega t)\cos(\phi) - \sin( \omega t)\sin(\phi) \right] \end{align} $$
If we correlate our signal with $\cos(\omega t)$ then we’re left with $A \cos(\phi)$, and similarly for sin. Thus:
$$ \begin{align} \frac{\mathrm{Corr}\left(s(t), \sin(\omega t)\right)}{\mathrm{Corr}\left(s(t), \cos(\omega t)\right)} &= \frac{A \sin(\phi)} {A \cos(\phi)} \newline \arctan\left(\frac{\mathrm{Corr}\left(s(t), \sin(\omega t)\right)}{\mathrm{Corr}\left(s(t), \cos(\omega t)\right)}\right) &= \phi \end{align} $$
The correlation operator itself is simple: it’s the sum of the product of the two signals at matching points in time.
All we need to do is figure out the exact times of our $s(t)$ samples, which can be derived by the sampling rate of the ADC.
All of the terms besides our measured signal samples $s(t)$ are knowable at compile-time, so we can again generate a lookup table for our microcontroller to use:
fn generate_sine_cosine_table(
signal_frequency: f64,
sampling_frequency: f64,
num_samples: usize,
) -> String {
let mut output = String::new();
output.push_str("pub const SINE_COSINE_TABLE: [(f32, f32); ");
output.push_str(&num_samples.to_string());
output.push_str("] = [\n");
for i in 0..num_samples {
let angle = 2.0 * PI * signal_frequency * (i as f64 * (1.0 / sampling_frequency));
let sine = angle.sin() as f32;
let cosine = angle.cos() as f32;
output.push_str(&format!(" ({:?}, {:?}),\n", sine, cosine));
}
output.push_str("];\n");
output
}
The build.rs script is then:
fn main() {
// lol, compile-time-programming by literally writing code to a file that we import
let out_dir = std::env::var("OUT_DIR").unwrap();
let dest_path = std::path::Path::new(&out_dir).join("constants.rs");
let mut f = File::create(&dest_path).unwrap();
let pdm_frequency: u32 = 100_000; // 100 kHz
f.write_all(format!("pub const PDM_FREQUENCY: u32 = {:?};\n", pdm_frequency).as_bytes())
.unwrap();
let pdm_length = 128;
let num_samples = 512;
let signal_frequency = pdm_frequency as f64 / pdm_length as f64;
let adc_frequency = 12_000_000.;
let adc_sample_cycles = 71.5;
let adc_sample_overhead_cycles = 12.5; // see reference manual section 11.6
let sampling_frequency = adc_frequency / (adc_sample_cycles + adc_sample_overhead_cycles);
f.write_all(
generate_sine_cosine_table(signal_frequency, sampling_frequency, num_samples).as_bytes(),
)
.unwrap();
f.write_all(generate_pdm_bsrr(pdm_length).as_bytes())
.unwrap();
/// ...
}
Finally, at runtime we’re left with this loop:
loop {
// start PDM emission via DMA
// start ADC via DMA
// wait for ADC to read NUM_SAMPLES
// then...
let mut sum_sine: f32 = 0.0;
let mut sum_cosine: f32 = 0.0;
let adc_buf = unsafe { &ADC_BUF[..] };
for i in 0..NUM_SAMPLES {
let (sine, cosine) = SINE_COSINE_TABLE[i];
sum_sine += adc_buf[i] as f32 * sine;
sum_cosine += adc_buf[i] as f32 * cosine;
}
let phase = sum_sine.atan2(sum_cosine);
// add latest phase reading to position estimation.
// this object also handles wraparound and hysteresis.
position_estimator.update(phase);
info!("Phase: {} Position: {}", phase, position_estimator.position);
if user_button.is_low() {
info!("Button pressed, zeroing");
position_estimator.position = 0.;
}
}
Precision
There are several parameters we need to select in the firmware to implement the measurement:
- The frequency at which we emit the PDM pulses
- The number of PDM pulses across which we divide a single sinusoid period
- The ADC sampling time
- The number of ADC samples we take for each phase calculation
Rather than try to calculate the ideal parameters from first principles (which would probably depend on all sorts of specifics like the PCB soldermask thickness and trace resistances), let’s just try them all and see what works best. More specifically: for each parameter configuration, which has the lowest standard deviation for multiple measurements taken from the same physical slide position?
Instead of reflashing the firmware for each set of parameters, we can write a special recorder firmware that can be controlled by a laptop to try different parameters configurations. Then we can stream the raw ADC readings back to the laptop, which lets us try a much wider variety parameters.
You can look at the parameter sweep notebook for the gory details, but here’s the summary in a plot:
There’s a lot going on here, let’s break it down. Schematically:
- The Y-axis corresponds to the standard deviation of recorded phases (lower is better — after all, the slide is never moving, so ideally all measurements would return the same value)
- The X-axis corresponds to the frequency for which we’re sending out the PDM pulses of the emitted sinusoidal signal. All data here are for 128 PDM segments and an X-axis spacing of 2 kHz.
- Each sub-plot corresponds to a different “window size” — i.e., how many ADC samples we correlate to derive a single phase measurement.
- Each colored line is a different ADC sampling frequency (the stm32f103’s ADC can sample over 8 different periods, and we’re trying all of them)
What stands out to me in the data itself:
- The higher the ADC frequency, the higher the PDM frequency needs to be before we start to pick up the signal (i.e., for the standard deviation to drop). This makes sense to me, as if the ADC frequency is much higher than the signal’s, our window of samples won’t see the signal change much at all, so it’s going to be dominated by noise and we’ll have no idea what the phase is.
- Increasing the window size tends to improve precision — makes sense, as it means we’re looking at more samples and (presumably) reducing the effect of noise.
- The lowest ADC sampling frequency (i.e., the longest ADC sampling period) tends to have the best performance.
- There’s a strange cat at 250 kHz; this is probably the point at which our ADC sampling rate isn’t fast enough to keep up with the signal itself. With 250 kHz / 128 PDM segments implies our emitted sinusoid is at about 1950 Hz
While in this static test increasing the window size and ADC sampling period looks best, that does mean it’ll reduce the rate at which we can actually calculate the phase, which limits how fast the slider can move before it loses track of its absolute position.
So, based on this survey I decided to set the parameters for the local, in-firmware calculation (as seen in the demo video) to be the point indicated by the red arrow:
- window size = 128
- PDM frequency = 222 kHz
- ADC sampling frequency = 222.2 kHz
It actually makes sense that the phase deviation is a minimum here: At this ADC sampling frequency and a window size of 128, we’re pretty much matching a full period of the emitted 128-segment PDM signal.
The timestamps in the demo video show about 1.5 ms between readings (0.6 kHz), which is about three times the ideal limit (222.2 kHz / 128 samples => 1.7 kHz), probably due to the time it takes to do correlation math, print to the host computer, and cycle through the Embassy async machinery. I’m sure a more optimized implementation could hit the limit by, e.g., calculating the correlations while the samples are being collected rather than afterwards.
As for the precision, taking 200ms worth of phase measurements from the printed logs (n = 124) while the slide isn’t moving, the standard deviation of the phase is 0.039 radians, which (for my PCB with 8 emitter keys = 9.4 mm) is a position error of about $ 0.039 * 9.4\,\mathrm{mm} / 2\pi = 0.6\,\mathrm{mm}$.
Honestly, this is way better than I expected — especially since the stm32f103 came out in 2007, the drive signal is created by just banging on GPIO, and only conditioning for the received signal is a fixed-gain amplifier (we’re not even filtering out the 50 Hz line noise).
Misc. tips / lessons learned
- I’m very happy with the workflow of streaming raw data to my computer over USB and then doing analysis in Python notebooks. This was also helpful for letting continue work on the project while I was traveling and away from the working hardware.
- I’m not super-well versed in the Python ecosystem, and it was spectacular asking LLMs like Claude stuff like, “Can you please plot an FFT of these data samples”, “Run these bad boys through a low pass filter at with cutoff at 1000 Hz”, “Can you please write an a phase accumulator that handles wraparound correctly”, etc. Being able to use my lightweight dictation app to just ramble out thoughts/ideas at an LLM was particularly satisfying.
- Even with LLM-assistance, plotting was more difficult than I expected:
- The JS-based interactive plots (Plotly, etc.) blew up when I tried to visualize raw data with just ~100k samples.
- The matplotlib-based static plotting libraries didn’t make it easy to read exact coordinates from a point on the plot interactively.
- Doing data aggregation with Polars was pretty good, but it wasn’t obvious to me how to aggregate + plot, e.g., both underlying data and their standard deviation across dimensions.
- Likely I just need to pick a Python plotting library and spend 20 hours getting fluent.
- The proliferation of complex type signatures in the Rust Embedded ecosystem still fails to spark joy — I’m stuck in the local optima of “literally just write everything in
fn main()
with a loop at the bottom” so I never have to write out the type signatures.
Future improvements
I probably won’t go further on this until I have some sort of robotics context-of-use. But for anyone who’s in the market for a project, here are a few ideas:
- Figure out how to make a parametric caliper emitter/reflector designs in PCB tools like atopile or some kind of KiCAD footprint generation script.
- Design something specifically to work with aluminum extrusion motion systems and see how cheap/accurate you can make it.
- Compare to more “off the shelf” positioning systems like magnetic tape or SteamVR trackers.
- Actually make this into a functional caliper by designing more suitable housing (as you can see from the video, my 3d printed base is just clamped to my desk).
Thanks
- Mitko for designing the PCB hardware. See the details on the hackday.io project page.
- Nathan Perry and Jeff McBride for helping me track down the intermittent stm32f103 freeze up to a bug in the CPU silicon. (Let’s all do our best to never run into that again!)