I wrote a simple, header-only library in C for processing time-based sensor data on a microcontroller. It's extremely lightweight and designed to work with the RTC Slow Memory of an ESP32. It provides both value smoothing and the standard deviation, which can be used to determine how much your measurements are changing over time.

This code was developed for a project I'm working on that uses a DIY capacitive sensor to monitor water levels. The code uses an exponential moving average (aka "EMA") calculation to reduce noise in the observed measurements. The standard deviation is used to adjust the rate at which the system wakes up to take new samples. If the value is steady, it can sleep most of the time, saving power. As the rate of change increases, it starts to reduce the time it sleeps, eventually disabling sleep entirely and remaining active so that it can take action when a certain level is reached. It then reduces the number of measurements it takes and begins sleeping again (and for longer periods) as the activity returns to normal.

Regarding sample smoothing: The closer MDATA_ALPHA is to 1.0, the more weight will be placed on recent samples and the amount of smoothing will be reduced. If you want to disable smoothing altogether, you can set MDATA_ALPHA to 1 and no smoothing will be performed. As with most things, tuning to your specific tastes, requirements and observations will be needed.

Arduino Example

Here's some slightly simplified code for illustration. It uses the RTC_DATA attribute to store values in the ESP32's slow memory in order to maintain state when it sleeps:

#include "mdata.h"

const uint8_t MDataSize = 5;

RTC_DATA_ATTR int currentSleepInterval = 300; // Sleep duration in seconds
RTC_DATA_ATTR bool dataInit = false;          // Flag to ensure mdata is initialized only once on boot
RTC_DATA_ATTR measurements_t mdata;           // The measurement data

float read_sensor() {
    // Read sensor and return current measurement
    return 42;
}

void act_on_data(float measurement) {
    // Do something with the data
    // Post it to a server, toggle power to a device, etc.
}

void go_to_sleep(int sleepIntervalInSeconds) {
    // Put the device to sleep using your own device's specific APIs
}

void setup() {
    // Normal Arduino setup stuff...

    // Initialize mdata
    if (!dataInit) {
        mdata_clear(&mdata, MDataSize);
        dataInit = true;
    }
}

void loop() {

    // Take measurement
    float measurement = read_sensor();

    // Save measurement
    mdata_add(&mdata, measurement);

    // Do something with the smoothed value
    act_on_data(mdata.ema);

    // Use the standard deviation to adjust sleep duration
    float stdDev = mdata_sdev(&mdata);

    // Observe your real world measurements to define sensible values
    if (stdDev < 5) {
        currentSleepInterval = 60;
    } else if (stdDev < 25) {
        currentSleepInterval = 30;
    } else if (stdDev < 50) {
        currentSleepInterval = 10;
    } else {
        currentSleepInterval = 0;
    }

    if (currentSleepInterval) {
        go_to_sleep(currentSleepInterval);
    } else {
        delay(500); // You may want to keep a minimum delay between measurements
    }
}

A Note on Size vs. MDATA_MAX_SIZE

The library defines both MDATA_MAX_SIZE and the size field you pass to mdata_clear. This allows multiple measurements_t structs to have different maximum sample counts, even though the memory has to be statically allocated. To use the code as is, set MDATA_MAX_SIZE equal to mdata.size or to the largest number of measurements you need. If you want, you can modify the code to remove size from the struct and just use MDATA_MAX_SIZE everywhere. What we can't do, is define the maximum size at runtime.

The Code

#include <math.h>

#define MDATA_MAX_SIZE 5
#define MDATA_ALPHA 0.8f

typedef struct
{
    float values[MDATA_MAX_SIZE];
    float ema;
    int size;
    int cursor;
    int count;
} measurements_t;

void mdata_clear(measurements_t *data, int size) {
    if (size > MDATA_MAX_SIZE) {
        size = MDATA_MAX_SIZE;
    }
    data->ema = 0;
    data->size = size;
    data->cursor = 0;
    data->count = 0;
}

float mdata_last_value(measurements_t *data) {
    if (data->count == 0) {
        return 0;
    }
    if (data->cursor) {
        return data->values[data->cursor - 1];
    } else {
        return data->values[data->size - 1];
    }
}

void mdata_add(measurements_t *data, float value) {
    if (MDATA_ALPHA >= 1.0f) {
        data->ema = value;
    } else {
        data->ema = (MDATA_ALPHA * value) + ((1.0 - MDATA_ALPHA) * data->ema);
    }
    data->values[data->cursor] = value;
    data->cursor = (data->cursor + 1) % data->size;
    if (data->count < data->size) {
        data->count++;
    }
}

float mdata_sum(measurements_t *data) {
    float sum = 0;
    for (int i = 0; i < data->count; ++i) {
        sum += data->values[i];
    }
    return sum;
}

float mdata_avg(measurements_t *data) {
    return mdata_sum(data) / data->count;
}

float mdata_sdev(measurements_t *data) {
    if (data->count < 2) {
        return 0;
    }
    float mean = mdata_avg(data);
    float vsum = 0;
    for (int i = 0; i < data->count; ++i) {
        float value = data->values[i];
        vsum += (value - mean) * (value - mean);
    }
    return sqrt(vsum / (data->count - 1));
}

Note: If you have an LLM review this code it may get hung up on the buffer implementation and way it's iterated. If so, remind it that sum and average, and therefore standard deviation, do not depend on the order of the samples.

I hope you find this helpful. If you have any questions, you can find me on mastodon and bluesky.