Measuring Head Acceleration in Mice with an External IMU

MPU6050 + Arduino UNO R4, 1 kHz binary stream

1 Overview

Most headstages have no IMU. This method adds one: a small MPU6050 module (about 0.5 g) mounted on the headstage and read by an Arduino UNO R4, streaming 3-axis acceleration and 3-axis angular velocity at 1 kHz over USB. Use it to detect movement vs. immobility and to flag motion artifacts in ephys or photometry.

The MPU6050 combines a 3-axis accelerometer, a 3-axis gyroscope, an on-chip temperature sensor and an I2C interface (up to 400 kHz).

2 Parts

  • MPU6050 breakout module (about 0.5 g)
  • Arduino UNO R4
  • 4 thin, flexible wires (keep I2C wires under about 30 cm)
  • USB cable to the PC

3 Specifications

Parameter Value
Supply voltage 3.0 - 5.5 V (onboard 3.3 V regulator, feed it 5 V)
Interface I2C, up to 400 kHz
Accelerometer range ±2 / ±4 / ±8 / ±16 g
Gyroscope range ±250 / ±500 / ±1000 / ±2000 °/s
Resolution (max) 16384 LSB/g (accel), 131 LSB/(°/s) (gyro)
Internal sample rate 1 kHz (accel), 8 kHz (gyro)
Temperature range -40 to +85 °C, ±1 °C
Pull-ups 5.1 kΩ on SCL and SDA (to onboard 3.3 V)
AD0 5.1 kΩ pull-down to GND on the board

Default ranges used in the sketch: ±2 g and ±250 °/s.

4 Pinout

Pin Function
VCC Power input (5 V recommended)
GND Ground
SCL I2C clock (built-in pull-up)
SDA I2C data (built-in pull-up)
AD0 I2C address select. Unconnected = 0x68, tied to 3.3 V = 0x69
AUX_CL Auxiliary I2C clock (external sensor, e.g. compass), usually unused
AUX_DA Auxiliary I2C data, usually unused
INT Interrupt output (data-ready), optional

5 I2C address

AD0 state 7-bit address 8-bit write / read
Unconnected / GND (default) 0x68 0xD0 / 0xD1
Tied to 3.3 V 0x69 0xD2 / 0xD3

6 Schematic and wiring

Figure 1: Schematic
Figure 2: Wiring
Module UNO R4 Notes
VCC 5V Onboard regulator steps down to 3.3 V
GND GND Shared ground required
SCL A5 (or SCL pin) Built-in pull-ups, no extra resistors
SDA A4 (or SDA pin) Built-in pull-ups, no extra resistors
AD0 - Leave unconnected (address 0x68)
AUX_CL / AUX_DA / INT - Leave unconnected

Minimum working connection: 4 wires (VCC, GND, SCL, SDA).

7 Mounting

Fix the sensor rigidly to the headstage or implant so it moves with the skull. Write down which sensor axis points forward, up and sideways.

8 Quick test: I2C scanner

Upload this first to confirm wiring and address:

#include <Wire.h>

void setup() {
  Serial.begin(115200);
  Wire.begin();
  Wire.setClock(400000);
  delay(1000);
  Serial.println("Scanning I2C...");
  for (byte addr = 1; addr < 127; addr++) {
    Wire.beginTransmission(addr);
    if (Wire.endTransmission() == 0) {
      Serial.print("Found device at 0x");
      Serial.println(addr, HEX);
    }
  }
  Serial.println("Done.");
}

void loop() {}

Expected output: Found device at 0x68

9 Streaming sketch (1 kHz binary output)

Reads accel + gyro at 1 kHz, calibrates the gyro at startup, computes pitch/roll with a complementary filter, and streams a 15-byte binary packet at 921600 baud.

// ============================================================
//  MPU6050 + Arduino UNO R4
//  - I2C at 400 kHz
//  - Gyro auto-calibration at startup
//  - 1 kHz sampling, 921600 baud binary stream
//
//  Wiring: VCC->5V, GND->GND, SCL->A5, SDA->A4
//
//  Binary packet (15 bytes/sample):
//
//    Byte 0     : 0xAA delimiter
//    Byte 1-2   : accel X raw (offset binary, 0g = 32768)
//    Byte 3-4   : accel Y raw
//    Byte 5-6   : accel Z raw
//    Byte 7-8   : gyro X raw  (offset binary, 0 d/s = 32768)
//    Byte 9-10  : gyro Y raw
//    Byte 11-12 : gyro Z raw
//    Byte 13-14 : acceleration magnitude (mg)
//
//  splot:
//    Data format    = binary
//    Separator byte = 170
//    Binary format  = u2,u2,u2,u2,u2,u2,u2
// ============================================================

#include <Arduino.h>
#include <Wire.h>
#include <math.h>

const uint8_t MPU_ADDR = 0x68;            // 0x69 if AD0 pulled high
const float   ACC_LSB_PER_G    = 16384.0f; // +/-2g
const float   GYRO_LSB_PER_DPS = 131.0f;   // +/-250 deg/s

// --- 1 kHz sampling ---
const uint32_t SAMPLE_INTERVAL_US = 1000;
uint32_t nextSampleTime;

// --- Complementary filter ---
const float ALPHA = 0.98f;
const float DT    = 0.001f;               // 1 kHz

// --- Calibration offsets ---
float gyroXoff = 0, gyroYoff = 0, gyroZoff = 0;

// --- Angle state ---
float pitch = 0, roll = 0;

// ------------------------------------------------------------
// Serial packet
// ------------------------------------------------------------

const uint8_t DELIMITER = 0xAA;

struct DataPacket
{
    uint8_t  delimiter;

    uint16_t ax;
    uint16_t ay;
    uint16_t az;

    uint16_t gx;
    uint16_t gy;
    uint16_t gz;

    uint16_t acceleration_mg;

} __attribute__((packed));

DataPacket packet;

// ============================================================
void setup()
{
    Serial.begin(921600);
    while (!Serial) delay(10);

    Wire.begin();
    Wire.setClock(400000);          // 400 kHz fast mode

    // Wake up the MPU6050
    Wire.beginTransmission(MPU_ADDR);
    Wire.write(0x6B);               // PWR_MGMT_1
    Wire.write(0x00);
    Wire.endTransmission();
    delay(100);

    // Gyro +/-250 deg/s
    Wire.beginTransmission(MPU_ADDR);
    Wire.write(0x1B);               // GYRO_CONFIG
    Wire.write(0x00);
    Wire.endTransmission();

    // Accel +/-2g
    Wire.beginTransmission(MPU_ADDR);
    Wire.write(0x1C);               // ACCEL_CONFIG
    Wire.write(0x00);
    Wire.endTransmission();

    calibrateGyro();                // keep module STILL ~2 s

    packet.delimiter = DELIMITER;
    nextSampleTime = micros();
}

// ============================================================
void calibrateGyro()
{
    const int N = 500;
    long sx = 0, sy = 0, sz = 0;

    for (int i = 0; i < N; i++)
    {
        Wire.beginTransmission(MPU_ADDR);
        Wire.write(0x43);           // GYRO_XOUT_H
        Wire.endTransmission(false);
        Wire.requestFrom(MPU_ADDR, (uint8_t)6, (uint8_t)true);

        sx += Wire.read() << 8 | Wire.read();
        sy += Wire.read() << 8 | Wire.read();
        sz += Wire.read() << 8 | Wire.read();
        delay(3);
    }

    gyroXoff = (float)sx / N;
    gyroYoff = (float)sy / N;
    gyroZoff = (float)sz / N;
}

// ============================================================
void loop()
{
    uint32_t now = micros();

    // Exact 1 kHz scheduler - no drift
    if ((int32_t)(now - nextSampleTime) >= 0)
    {
        nextSampleTime += SAMPLE_INTERVAL_US;

        // --- Read accel + gyro (14 bytes) ---
        Wire.beginTransmission(MPU_ADDR);
        Wire.write(0x3B);           // ACCEL_XOUT_H
        Wire.endTransmission(false);
        Wire.requestFrom(MPU_ADDR, (uint8_t)14, (uint8_t)true);

        int16_t axRaw = Wire.read() << 8 | Wire.read();
        int16_t ayRaw = Wire.read() << 8 | Wire.read();
        int16_t azRaw = Wire.read() << 8 | Wire.read();
        Wire.read(); Wire.read();   // temperature (skip)
        int16_t gxRaw = Wire.read() << 8 | Wire.read();
        int16_t gyRaw = Wire.read() << 8 | Wire.read();
        int16_t gzRaw = Wire.read() << 8 | Wire.read();

        // --- Physical units ---
        float ax = axRaw / ACC_LSB_PER_G;
        float ay = ayRaw / ACC_LSB_PER_G;
        float az = azRaw / ACC_LSB_PER_G;

        float gx = (gxRaw - gyroXoff) / GYRO_LSB_PER_DPS;
        float gy = (gyRaw - gyroYoff) / GYRO_LSB_PER_DPS;
        float gz = (gzRaw - gyroZoff) / GYRO_LSB_PER_DPS;

        // --- Complementary filter (Pitch/Roll) ---
        float accRoll  = atan2f(ay, az) * 57.29578f;
        float accPitch = atan2f(-ax, sqrtf(ay * ay + az * az)) * 57.29578f;

        roll  = ALPHA * (roll  + gx * DT) + (1.0f - ALPHA) * accRoll;
        pitch = ALPHA * (pitch + gy * DT) + (1.0f - ALPHA) * accPitch;

        // ====================================================
        // SERIAL OUTPUT - 15-byte binary packet @ 1 kHz
        // ====================================================

        // All signed raw values -> offset binary (0 = 32768)
        packet.ax = (uint16_t)(axRaw + 32768);
        packet.ay = (uint16_t)(ayRaw + 32768);
        packet.az = (uint16_t)(azRaw + 32768);

        packet.gx = (uint16_t)(gxRaw + 32768);
        packet.gy = (uint16_t)(gyRaw + 32768);
        packet.gz = (uint16_t)(gzRaw + 32768);

        // Magnitude in milli-g
        float acceleration = sqrtf(ax * ax + ay * ay + az * az);

        uint32_t mg = (uint32_t)(acceleration * 1000.0f);
        if (mg > 65535) mg = 65535;
        packet.acceleration_mg = (uint16_t)mg;

        packet.delimiter = DELIMITER;

        Serial.write((uint8_t*)&packet, sizeof(packet));
    }
}

10 Serial packet format

15 bytes per sample, little-endian 16-bit fields, 1 kHz, 921600 baud:

Bytes Field Encoding
0 delimiter always 0xAA (170)
1-2 accel X offset binary: value - 32768 = raw counts
3-4 accel Y offset binary
5-6 accel Z offset binary
7-8 gyro X offset binary
9-10 gyro Y offset binary
11-12 gyro Z offset binary
13-14 acceleration magnitude milli-g (1000 = 1 g)

splot settings:

Data format    = binary
Separator byte = 170
Binary format  = u2,u2,u2,u2,u2,u2,u2

10.1 Decoding in Python

The delimiter byte 0xAA can also appear inside the data, so this reader checks for a delimiter at the start of the next packet too, and resynchronizes if it is not there. Close the Arduino Serial Monitor first (only one program can hold the port).

import struct, numpy as np, serial

PORT, BAUD, PKT = "COM5", 921600, 15      # change PORT for your system

def read_packets(ser, n):
    buf, out = bytearray(), []
    while len(out) < n:
        buf += ser.read(max(PKT, ser.in_waiting))
        while len(buf) >= 2 * PKT:
            if buf[0] == 0xAA and buf[PKT] == 0xAA:
                out.append(struct.unpack("<7H", bytes(buf[1:PKT])))
                del buf[:PKT]
            else:
                del buf[0]
    return np.array(out, dtype=np.uint16)

with serial.Serial(PORT, BAUD, timeout=0.1) as ser:
    raw = read_packets(ser, 10_000)       # about 10 s at 1 kHz
np.save("imu_raw.npy", raw)

ax, ay, az, gx, gy, gz, mg = raw.T.astype(float)
ax_g   = (ax - 32768) / 16384.0           # g
gx_dps = (gx - 32768) / 131.0             # deg/s

11 Serial bandwidth and timing

Item Time / rate
Serial line 921600 baud
Packet on wire 15 bytes x 10 bits = 150 bits, about 163 µs
Payload throughput 15 kB/s (about 13 % of the line)
I2C read (14 bytes at 400 kHz) about 400 µs
Math + filter about 30 µs
Total per 1 ms cycle about 600 µs (about 40 % headroom)

115200 baud is not fast enough for 1 kHz streaming; 921600 is required.

12 Conversions

Acceleration (g)   = (raw - 32768) / 16384        (offset binary -> g)
Gyro (deg/s)       = (raw - 32768) / 131          (offset binary -> deg/s)
Temperature (C)    = raw / 340 + 36.53            (register 0x41, signed)
Accel roll  (deg)  = atan2(ay, az) * 57.2958
Accel pitch (deg)  = atan2(-ax, sqrt(ay^2 + az^2)) * 57.2958
Magnitude (mg)     = sqrt(ax^2 + ay^2 + az^2) * 1000

Other ranges change the scale factors:

Register Setting Accel LSB/g Gyro LSB/(°/s)
ACCEL_CONFIG (0x1C) = 0 ±2 g 16384 -
= 8 ±4 g 8192 -
= 16 ±8 g 4096 -
= 24 ±16 g 2048 -
GYRO_CONFIG (0x1B) = 0 ±250 °/s - 131
= 8 ±500 °/s - 65.5
= 16 ±1000 °/s - 32.8
= 24 ±2000 °/s - 16.4

13 Calibration

  • The sketch averages 500 gyro samples at startup (about 2 s). Keep the module perfectly still during this window, or the gyro zero-offset will be wrong and angles will drift.
  • The streamed gyro values are the raw counts. The startup offsets are only used by the on-board pitch/roll filter, so subtract a bias from a quiet period when analyzing the stream.
  • Accel zero-offset is not calibrated. If needed, measure the resting values on each axis and subtract them.
  • At rest the magnitude is about 1 g (gravity). Movement is the deviation from 1 g.

14 Notes and limitations

  • Yaw is not reliable without a magnetometer, because the gyro drifts. Use pitch/roll only.
  • Reading accel only (6 bytes) instead of 14 frees about 200 µs per cycle.
  • The Arduino IDE Serial Monitor cannot display binary output.
  • Only one program can hold the serial port at a time.
  • I2C wires should stay under about 30 cm at 400 kHz.
  • The packet has no timestamp. To align with ephys, send a sync pulse that is also recorded by the ephys system, or start both recordings from one script.

15 Example recording

Figure 3: Example traces: ax, ay, az, gx, gy, gz, magnitude

16 Troubleshooting

Problem Fix
I2C scanner finds nothing Check SDA to A4, SCL to A5, shared GND; scan again
Found at 0x69 instead of 0x68 AD0 is pulled high; set MPU_ADDR to 0x69
Flat lines, mg = 0 I2C failing; run the scanner first
Garbage in Serial Monitor Expected, it is binary; use the decoder or splot
Stuttering plot Receiver too slow, or Serial Monitor also open
Angles drift Module moved during startup calibration; reset while still
Random dropouts at 1 kHz Shorten I2C wires; check soldered joints

17 Useful registers

Register Address Function
PWR_MGMT_1 0x6B 0x00 = wake from sleep
ACCEL_CONFIG 0x1C Full-scale range
GYRO_CONFIG 0x1B Full-scale range
ACCEL_XOUT_H 0x3B Burst-read start (accel, temp, gyro: 14 bytes)
GYRO_XOUT_H 0x43 Gyro burst-read start (6 bytes)
TEMP_OUT_H 0x41 Temperature (2 bytes, signed)
WHO_AM_I 0x75 Should read 0x68