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
| 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/s11 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
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 |