Making electronic calipers

← Back to Kevin's homepagePublished: 2024 November 2

Have 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:

calipers

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:

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:

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:

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:

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:

What stands out to me in the data itself:

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:

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

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:

Thanks