How to achieve stable sampling rate (1 kHz) when reading IMU data and sending over Serial on ESP32?

theluciansm
Posts: 4
Joined: Thu Oct 02, 2025 7:28 pm

How to achieve stable sampling rate (1 kHz) when reading IMU data and sending over Serial on ESP32?

Postby theluciansm » Thu Oct 02, 2025 7:45 pm

Hello everyone,

I am working with an ESP32 and an ICM-45686 IMU.
My goal is to acquire accelerometer and gyroscope data at 1.024 kHz and send it to a PC via serial. On the PC side, I have a Python script that receives the data and saves it.

I would like to evaluate whether the sampling rate of the ESP32 is actually stable. To check this, I compared the ESP32 data with a commercial data acquisition system, both synchronized by a trigger.

In the figure attached:

Black: measurement from the commercial system

Red: measurement from the ESP32

Blue: difference between the two signals

The signal is a measurement of a periodic movement of an object with 10 Hz of frequency.

We can observe that the difference (blue) is not just a fixed offset, but varies over time: the ESP32 signal starts slightly delayed and then seems to get ahead of the reference signal.

๐Ÿ‘‰ How should I interpret this behavior?
๐Ÿ‘‰ Does this variation in the blue residual indicate that the ESP32 sampling rate is not stable (jitter), or could there be another explanation?

I accept any suggestions.
THanks!

The esp32 code is below (using arduino IDE):

Code: Select all

/*******************************************************************************************
 *  ICM-45686 โ€“ 1 kHz Acquisition + Laser Trigger                                         *
 *                                                                                        *
 *  FEATURES:                                                                             *
 *   โ€ข Reads accelerometer and gyroscope signals from the ICM-45686 IMU at 1 kHz          *
 *   โ€ข Sends the data over serial in tabular format                                       *
 *   โ€ข Activates the laser (GPIO 17) immediately after button detection (GPIO 33)         *
 *                                                                                        *
 *  Author: Lucian Ribeiro da Silva โ€“ Jun/2025                                            *
 *******************************************************************************************/

#include <Arduino.h>
#include <SPI.h>
#include "ICM45686.h"
#include "soc/gpio_reg.h"  // For direct register manipulation (if needed)
#include "driver/gpio.h"
#include "soc/io_mux_reg.h"
#include "soc/soc.h"


/* โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
 *  Application pin definitions
 * โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ */
constexpr int CS_PIN        = 5;    // IMU Chip Select on the SPI bus
constexpr int INT1_PIN      = 4;    // IMU interrupt pin (not used)
// constexpr int PINO_BOTAO    = 17;   // <-- REMOVED
constexpr int PINO_TRIGGER  = 33;   // Digital output to activate the laser
constexpr int PINO_TRIGGER_SCADAS  = 27;   // Digital trigger output for SCADAS
constexpr int DEBUG_PIN     = 2;    // On-board LED for debugging (optional)
constexpr int tempoHIGH = 15000;
constexpr int Fs_loop = 1024; // frequency of signal transmission via serial (not the sensor ODR, be careful)
int Ts = 0;
/* โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
 *  Global variables
 * โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ */
int32_t off_ax, off_ay, off_az;     // Acceleration offsets
int32_t off_gx, off_gy, off_gz;     // Gyroscope offsets

ICM456xx IMU(SPI, CS_PIN);          // IMU object connected via SPI

volatile int triggerAtivado = 0;    // <-- MODIFIED: renamed from 'botaoPressionado'
unsigned long tempoAcionamento = 0; // Moment when the button was pressed

char buffer[512];                   // Buffer for serial output formatting

/* โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
 *  Button interrupt // <-- REMOVED
 * โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ */
// void handleBotao() {
//     if (botaoPressionado == 0) {       // Ensures only the first click is registered
//         botaoPressionado = 1;
//         tempoAcionamento = millis();   // Saves the activation timestamp
//     }
// }

/* โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
 *  System initial setup
 * โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ */
void setup() {
    Serial.begin(1500000);
    while (!Serial) {}

    // pinMode(PINO_BOTAO, INPUT_PULLDOWN); // <-- REMOVED
    // attachInterrupt(digitalPinToInterrupt(PINO_BOTAO), handleBotao, RISING); // <-- REMOVED

    pinMode(PINO_TRIGGER, OUTPUT);
    digitalWrite(PINO_TRIGGER, LOW);

    pinMode(PINO_TRIGGER_SCADAS, OUTPUT);
    digitalWrite(PINO_TRIGGER_SCADAS, LOW);

    pinMode(DEBUG_PIN, OUTPUT);
    digitalWrite(DEBUG_PIN, LOW);

    SPI.begin(18, 19, 23, CS_PIN);
    pinMode(CS_PIN, OUTPUT);
    digitalWrite(CS_PIN, HIGH);

    pinMode(INT1_PIN, INPUT_PULLUP);

    if (IMU.begin() != 0) {
        Serial.println("# ERROR: IMU did not initialize");
        while (true) delay(1000);
    }

    IMU.startAccel(1600, 2);    // ยฑ2g
    IMU.startGyro (1600, 15);   // ยฑ15.625 ยฐ/s
    delay(50);

    int64_t sum[6] = {0};
    inv_imu_sensor_data_t d;
    for (int i = 0; i < 500; ++i) {
        IMU.getDataFromRegisters(d);
        sum[0] += d.accel_data[0];
        sum[1] += d.accel_data[1];
        sum[2] += d.accel_data[2];
        sum[3] += d.gyro_data[0];
        sum[4] += d.gyro_data[1];
        sum[5] += d.gyro_data[2];
        delayMicroseconds(1000);
    }
    off_ax = sum[0] / 500;
    off_ay = sum[1] / 500;
    off_az = sum[2] / 500;
    off_gx = sum[3] / 500;
    off_gy = sum[4] / 500;
    off_gz = sum[5] / 500;

    Serial.println("trigger\tax\tay\taz\tgx\tgy\tgz"); // <-- MODIFIED: updated header

    Ts = (1000000UL / Fs_loop);
}

/* โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
 *  Main loop โ€“ runs at 1 kHz
 * โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ */
void loop() {
    static uint32_t nextSampleTime = micros() + Ts;
    
    // Busy-wait until the exact sampling moment
    while (micros() < nextSampleTime) {
        // Small pause to reduce CPU usage
        __asm__ volatile("nop");
    }
    
    // Update the next sample time WITHOUT accumulating error
    nextSampleTime += Ts;
    
    // Check if we are behind schedule (drift protection)
    if (micros() > nextSampleTime) {
        // If we are too late, resynchronize
        nextSampleTime = micros() + Ts;
    }
// --- ADDED: Python command "listener" ---
if (Serial.available() > 0 && triggerAtivado == 0) {
    int comando = Serial.parseInt();
   if (comando == 1) {
        triggerAtivado = 1;
        digitalWrite(PINO_TRIGGER, HIGH);
        digitalWrite(PINO_TRIGGER_SCADAS, HIGH);
        //REG_WRITE(GPIO_OUT_W1TS_REG, (1ULL << PINO_TRIGGER) | (1ULL << PINO_TRIGGER_SCADAS));

    }
}


    // IMU reading
    inv_imu_sensor_data_t d;
    IMU.getDataFromRegisters(d);

    int32_t ax = d.accel_data[0] - off_ax;
    int32_t ay = d.accel_data[1] - off_ay;
    int32_t az = d.accel_data[2] - off_az;
    int32_t gx = d.gyro_data[0]  - off_gx;
    int32_t gy = d.gyro_data[1]  - off_gy;
    int32_t gz = d.gyro_data[2]  - off_gz;

    snprintf(buffer, sizeof(buffer),
             "%d\t%ld\t%ld\t%ld\t%ld\t%ld\t%ld",
             triggerAtivado, ax, ay, az, gx, gy, gz);
    Serial.println(buffer);
}

Attachments
Screenshot 2025-10-02 165711.png
Screenshot 2025-10-02 165711.png (854.34 KiB) Viewed 4057 times

Sprite
Espressif staff
Espressif staff
Posts: 10599
Joined: Thu Nov 26, 2015 4:08 am

Re: How to achieve stable sampling rate (1 kHz) when reading IMU data and sending over Serial on ESP32?

Postby Sprite » Fri Oct 03, 2025 9:13 am

The error is fairly small - I'm eyeballing that at the end the amplitude is about 1/3rd of the actual waveforms, meaning it leads or lags by (sin-1(0.333)/2*pi=)0.04 cycles. On a total of 80 cycles, that's about 0.05%. That is probably be explained by the fact that you're using ints for Ts, nextSampleTime etc - your Ts should be (1000000/1024)=976.562 but because you store it as an int, it's 976. That leads to an actual frequency of (1000000/976)=1024.59Hz, which indeed is off by 0.05%.

theluciansm
Posts: 4
Joined: Thu Oct 02, 2025 7:28 pm

Re: How to achieve stable sampling rate (1 kHz) when reading IMU data and sending over Serial on ESP32?

Postby theluciansm » Fri Oct 03, 2025 2:27 pm

The error is fairly small - I'm eyeballing that at the end the amplitude is about 1/3rd of the actual waveforms, meaning it leads or lags by (sin-1(0.333)/2*pi=)0.04 cycles. On a total of 80 cycles, that's about 0.05%. That is probably be explained by the fact that you're using ints for Ts, nextSampleTime etc - your Ts should be (1000000/1024)=976.562 but because you store it as an int, it's 976. That leads to an actual frequency of (1000000/976)=1024.59Hz, which indeed is off by 0.05%.
Hi @Sprite, thanks a lot for your explanation.
That was a very good observation โ€” I hadnโ€™t considered the effect of using int for Ts and nextSampleTime. Iโ€™ll update the code to use the correct precision and then come back here to share the new results.

theluciansm
Posts: 4
Joined: Thu Oct 02, 2025 7:28 pm

Re: How to achieve stable sampling rate (1 kHz) when reading IMU data and sending over Serial on ESP32?

Postby theluciansm » Fri Oct 03, 2025 3:20 pm

The error is fairly small - I'm eyeballing that at the end the amplitude is about 1/3rd of the actual waveforms, meaning it leads or lags by (sin-1(0.333)/2*pi=)0.04 cycles. On a total of 80 cycles, that's about 0.05%. That is probably be explained by the fact that you're using ints for Ts, nextSampleTime etc - your Ts should be (1000000/1024)=976.562 but because you store it as an int, it's 976. That leads to an actual frequency of (1000000/976)=1024.59Hz, which indeed is off by 0.05%.
Hi @Sprite, thanks a lot for your explanation.
That was a very good observation โ€” I hadnโ€™t considered the effect of using int for Ts and nextSampleTime. Iโ€™ll update the code to use the correct precision and then come back here to share the new results.
I changed for the following code

Code: Select all



/***********
 *  ICM-45686 โ€“ Aquisiรงรฃo a 1 kHz + Disparo de Laser
 *
 *  Correรงรฃo: uso de double para Ts e uint64_t para temporizaรงรฃo,
 *  evitando truncamento (976 vs 976.562 ยตs) que gerava drift.
 *
 *  Autor: Lucian Ribeiro da Silva โ€“ Rev. Out/2025
 ***********/

#include <Arduino.h>
#include <SPI.h>
#include "ICM45686.h"
#include "soc/gpio_reg.h"
#include "driver/gpio.h"
#include "soc/io_mux_reg.h"
#include "soc/soc.h"

/* โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
 *  Definiรงรตes de pinos da aplicaรงรฃo
 * โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ */
constexpr int CS_PIN        = 5;
constexpr int INT1_PIN      = 4;
constexpr int PINO_TRIGGER  = 33;
constexpr int PINO_TRIGGER_SCADAS = 27;
constexpr int DEBUG_PIN     = 2;

constexpr double Fs_loop = 1024.0;   // Frequรชncia de envio (Hz)
double Ts;                           // Perรญodo em microssegundos (preciso)
                                     
int32_t off_ax, off_ay, off_az;
int32_t off_gx, off_gy, off_gz;

ICM456xx IMU(SPI, CS_PIN);

volatile int triggerAtivado = 0;
unsigned long tempoAcionamento = 0;

char buffer[128];

/* โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
 *  Setup
 * โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ */
void setup() {
    Serial.begin(1500000);
    while (!Serial) {}

    pinMode(PINO_TRIGGER, OUTPUT);
    digitalWrite(PINO_TRIGGER, LOW);

    pinMode(PINO_TRIGGER_SCADAS, OUTPUT);
    digitalWrite(PINO_TRIGGER_SCADAS, LOW);

    pinMode(DEBUG_PIN, OUTPUT);
    digitalWrite(DEBUG_PIN, LOW);

    SPI.begin(18, 19, 23, CS_PIN);
    pinMode(CS_PIN, OUTPUT);
    digitalWrite(CS_PIN, HIGH);

    pinMode(INT1_PIN, INPUT_PULLUP);

    if (IMU.begin() != 0) {
        Serial.println("# ERRO: IMU nรฃo inicializou");
        while (true) delay(1000);
    }

    IMU.startAccel(1600, 2);    // ยฑ2g
    IMU.startGyro (1600, 15);   // ยฑ15,625ยฐ/s
    delay(50);

    int64_t sum[6] = {0};
    inv_imu_sensor_data_t d;
    for (int i = 0; i < 500; ++i) {
        IMU.getDataFromRegisters(d);
        sum[0] += d.accel_data[0];
        sum[1] += d.accel_data[1];
        sum[2] += d.accel_data[2];
        sum[3] += d.gyro_data[0];
        sum[4] += d.gyro_data[1];
        sum[5] += d.gyro_data[2];
        delayMicroseconds(1000);
    }
    off_ax = sum[0] / 500;
    off_ay = sum[1] / 500;
    off_az = sum[2] / 500;
    off_gx = sum[3] / 500;
    off_gy = sum[4] / 500;
    off_gz = sum[5] / 500;

    Serial.println("trigger\tax\tay\taz\tgx\tgy\tgz");

    // Agora Ts รฉ armazenado com precisรฃo em double
    Ts = 1000000.0 / Fs_loop; // ~976.562 ยตs
}

/* โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
 *  Loop principal โ€“ executa a 1 kHz
 * โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ */
void loop() {
    static uint64_t nextSample = micros();  // uint64_t para evitar overflow

    // espera atรฉ o tempo exato
    while (micros() < nextSample) {}
    nextSample += (uint64_t)Ts;  // incremento com precisรฃo em double

    // --- Ouvinte de comando via Serial ---
    if (Serial.available() > 0 && triggerAtivado == 0) {
        int comando = Serial.parseInt();
        if (comando == 1) {
            triggerAtivado = 1;
            digitalWrite(PINO_TRIGGER, HIGH);
            digitalWrite(PINO_TRIGGER_SCADAS, HIGH);
        }
    }

    // Leitura da IMU
    inv_imu_sensor_data_t d;
    IMU.getDataFromRegisters(d);

    int32_t ax = d.accel_data[0] - off_ax;
    int32_t ay = d.accel_data[1] - off_ay;
    int32_t az = d.accel_data[2] - off_az;
    int32_t gx = d.gyro_data[0]  - off_gx;
    int32_t gy = d.gyro_data[1]  - off_gy;
    int32_t gz = d.gyro_data[2]  - off_gz;

    snprintf(buffer, sizeof(buffer),
             "%d\t%ld\t%ld\t%ld\t%ld\t%ld\t%ld",
             triggerAtivado, ax, ay, az, gx, gy, gz);
    Serial.println(buffer);
}
But Iโ€™m still observing a phase shift between the signals.
I attached a screenshot from the beginning and the end of the measurement.
Could the error be due to sending a large amount of data through the serial interfaceโ€”in other words, could the serial communication be bottlenecking the system? The sensorโ€™s ODR is set to 1600, since it cannot be configured to 1024. Could this also be causing some delay or advance in the signals?
Attachments
phase_at_begin.png
phase_at_begin.png (223.57 KiB) Viewed 4033 times
phase_at_end.png
phase_at_end.png (228.28 KiB) Viewed 4033 times
all_measurement.png
all_measurement.png (982.32 KiB) Viewed 4033 times

Sprite
Espressif staff
Espressif staff
Posts: 10599
Joined: Thu Nov 26, 2015 4:08 am

Re: How to achieve stable sampling rate (1 kHz) when reading IMU data and sending over Serial on ESP32?

Postby Sprite » Sat Oct 04, 2025 12:37 am

No real use making Ts a double if you then go cast it to an int anyway:

Code: Select all

nextSample += (uint64_t)Ts
Suggest you make nextSample a double as well.

esp_man
Posts: 25
Joined: Tue Mar 21, 2023 12:04 pm

Re: How to achieve stable sampling rate (1 kHz) when reading IMU data and sending over Serial on ESP32?

Postby esp_man » Mon Oct 06, 2025 11:23 am

The sensorโ€™s ODR is set to 1600, since it cannot be configured to 1024. Could this also be causing some delay or advance in the signals?
If the sensor is sample rate generator itself, it may cause errors. But not delay or advance, but the sample rate variations. The delay or advance is only a further consequence.
It is typical for many MEMS devices that the Output Data Rate is generated by internal RC oscillator. Therefore, it is inherently of low accuracy.

theluciansm
Posts: 4
Joined: Thu Oct 02, 2025 7:28 pm

Re: How to achieve stable sampling rate (1 kHz) when reading IMU data and sending over Serial on ESP32?

Postby theluciansm » Mon Oct 06, 2025 12:45 pm

No real use making Ts a double if you then go cast it to an int anyway:

Code: Select all

nextSample += (uint64_t)Ts
Suggest you make nextSample a double as well.
Hi @Sprite,
Thanks again for your help.
Iโ€™ve modified my code, and now Iโ€™m able to synchronize both signals (ESP vs. commercial system).
Iโ€™m attaching the signals below.
Thank you very much for your support!
Attachments
Screenshot 2025-10-06 094415.png
Screenshot 2025-10-06 094415.png (1.26 MiB) Viewed 4003 times

Sprite
Espressif staff
Espressif staff
Posts: 10599
Joined: Thu Nov 26, 2015 4:08 am

Re: How to achieve stable sampling rate (1 kHz) when reading IMU data and sending over Serial on ESP32?

Postby Sprite » Tue Oct 07, 2025 1:12 am

Glad to hear you made it work!

Return to โ€œESP32 Arduinoโ€

Who is online

Users browsing this forum: ChatGPT-User, Google [Bot] and 3 guests