Kalman filter tilt angle with an MPU6050 on an Arduino Uno
An MPU6050’s accelerometer and gyro fused by a Kalman filter into roll and pitch, plotted beside the raw angles, with a level LED. The whole build, an Arduino Uno and 5 more parts, runs here in your browser on the firmware below; open it in the editor to change the wiring or the code and run it again.
Teaching? Assign it to your class in one click.
Intermediate About 30 minutes to build and run. Runs in your browser. Free, and no account needed.
The parts retail for about $33. On Mokxi they're free.
What you'll learn
- Sensor fusion: combining a noisy sensor with a drifting one
- Reading raw register values from an I2C sensor
- Roll and pitch from accelerometer axes with
atan2 - Comparing a Kalman filter with a complementary filter
- Plotting several values live on the Serial Plotter
- Difficulty: Intermediate
- Time: About 30 minutes
- Board: Arduino Uno R3
- Parts: 8
How it works
An MPU6050 has an accelerometer and a gyroscope, and both measure tilt in different ways. The accelerometer is steady on average but noisy. The gyro is smooth but slowly drifts. The sketch reads both 100 times a second over I2C on A4 and A5.
It feeds them into a Kalman filter for roll and pitch. The filter predicts the angle from the gyro and corrects it toward the accelerometer. It also computes the gyro alone and a simple complementary filter (93% gyro, 7% accelerometer) for comparison.
The Serial Plotter at 115200 baud shows all the lines. The green LED on pin 6 lights when both angles are within 2 degrees of level. Otherwise the red LED on pin 7 lights.
Parts list
8 parts, plus the jumper wires. They retail for about $33; on Mokxi they're free.
- 1 × Arduino Uno R3In Starter Kit, Claw Machine Kit, Vending Machine Kit.
- 1 × Half breadboard
- 2 × Resistor, 220 ΩIn Starter Kit, Claw Machine Kit, Halloween Prop Kit.
- 1 × LED, greenIn Starter Kit, Halloween Prop Kit, Logic Lab Kit.
- 1 × LED, redIn Starter Kit, Halloween Prop Kit, Logic Lab Kit.
- 1 × Accelerometer and gyroscope, MPU6050In Desk Buddy Kit, Laser Sword Kit, Escape Box Kit.
- 1 × Power, 5 V
Kits that can build it
These 2 starter kits have every part in the circuit. Each kit's page lists what else it can build.
Wiring table
Every connection, pin by pin, read from the circuit itself. Each row joins the pin on the left to the pin on the right, by a jumper wire or a breadboard strip.
| Part and pin | Connects to |
|---|---|
| Resistor, 220 Ω (2) pin 1 | Arduino Uno R3 pin 7 |
| Resistor, 220 Ω (1) pin 1 | Arduino Uno R3 pin 6 |
| Accelerometer and gyroscope, MPU6050 pin SDA | Arduino Uno R3 pin A4 |
| Accelerometer and gyroscope, MPU6050 pin SCL | Arduino Uno R3 pin A5 |
| LED, green pin A | Resistor, 220 Ω (1) pin 2 |
| LED, green pin C | Ground |
| LED, red pin A | Resistor, 220 Ω (2) pin 2 |
| LED, red pin C | Ground |
| Accelerometer and gyroscope, MPU6050 pin VCC | 5 V |
| Accelerometer and gyroscope, MPU6050 pin GND | Ground |
The note on the canvas
Word for word, as it sits beside the circuit in the editor.
Try it
1. Press Run, then open the Serial Plotter (115200 baud). Flat, every line sits at 0 and the green LED is lit.
2. Drag the MPU6050 to tilt it. The red LED lights, and the kalRoll and kalPitch lines settle on the new angle.
3. Compare the four lines for each axis: the accelerometer's angle, the gyro alone, the complementary filter and the Kalman filter.
4. Open the sketch and change the 0.93 of the complementary filter, or call setRmeasure() on the Kalman filters, to see how each one follows.
The code
The firmware exactly as the editor opens it. Change a line there and press Run: it compiles in the browser.
// Kalman filter tilt angle: an MPU6050's accelerometer and gyroscope fused
// with TKJ Electronics' Kalman filter library into a roll and a pitch angle
// that are both smooth and drift-free, plotted beside the raw estimates on
// the Serial Plotter, with a bubble-level pair of LEDs.
//
// MPU6050 SDA -> A4 SCL -> A5 VCC -> 5V GND -> GND
// LEDs green on pin 6, red on pin 7, each through 220 ohms to GND
//
// TWO SENSORS, TWO FLAWS. Held still, the accelerometer measures only
// gravity, so atan2 of its axes gives the tilt; but every bump and vibration
// is acceleration too, so the angle is noisy. The gyroscope measures how
// fast the board turns; adding that up gives a smooth angle, but every
// small error adds up with it and the angle drifts away.
//
// THE KALMAN FILTER keeps two numbers, the angle and the gyro's bias (its
// reading when it is not turning), and how sure it is of each. Every sample
// it predicts the new angle from the gyro rate less the bias, then corrects
// the prediction toward the accelerometer's angle, by more when it is less
// sure of the prediction. Q_angle, Q_bias and R_measure (0.001, 0.003 and
// 0.03 by default) say how much to trust each source.
//
// THE COMPLEMENTARY FILTER beside it is the one-line alternative most
// balancing robots use: 93% gyro and 7% accelerometer every sample.
//
// Open the Serial Plotter at 115200 baud to see all four lines for each
// axis. Things to try: drag the MPU6050 to tilt it and watch the gyro-only
// line wander from the others while the Kalman line follows the tilt. Hold
// it level within 2 degrees and the green LED lights; tilt it and the red one does.
#include <Wire.h>
#include <Kalman.h>
const uint8_t MPU_ADDR = 0x68;
const byte GREEN_LED = 6;
const byte RED_LED = 7;
const float LEVEL_WITHIN = 2.0; // degrees either way counts as level
const unsigned long SAMPLE_US = 10000; // 100 samples a second
Kalman kalmanRoll;
Kalman kalmanPitch;
float gyroRoll, gyroPitch; // the gyro alone, added up
float compRoll, compPitch; // the complementary filter
float kalRoll, kalPitch; // the Kalman filter
unsigned long lastSample;
byte printEvery = 0;
int16_t accX, accY, accZ, gyroX, gyroY;
void writeRegister(uint8_t reg, uint8_t value) {
Wire.beginTransmission(MPU_ADDR);
Wire.write(reg);
Wire.write(value);
Wire.endTransmission();
}
bool readSensor() {
Wire.beginTransmission(MPU_ADDR);
Wire.write(0x3B); // ACCEL_XOUT_H: accelerometer, temperature, gyroscope
if (Wire.endTransmission(false) != 0) return false;
if (Wire.requestFrom(MPU_ADDR, (uint8_t)14) != 14) return false;
uint8_t data[14];
for (uint8_t i = 0; i < 14; i++) data[i] = Wire.read();
// Each value is two bytes, high byte first. Bytes 6 and 7 are the
// temperature and 12 and 13 the gyro's Z axis, not used here.
accX = data[0] << 8 | data[1];
accY = data[2] << 8 | data[3];
accZ = data[4] << 8 | data[5];
gyroX = data[8] << 8 | data[9];
gyroY = data[10] << 8 | data[11];
return true;
}
// The accelerometer's angles in degrees. Pitch is kept to -90..90 so the
// two angles never fight each other near upside down.
float accelRoll() { return atan2(accY, accZ) * RAD_TO_DEG; }
float accelPitch() { return atan(-accX / sqrt((float)accY * accY + (float)accZ * accZ)) * RAD_TO_DEG; }
void setup() {
Serial.begin(115200);
pinMode(GREEN_LED, OUTPUT);
pinMode(RED_LED, OUTPUT);
Wire.begin();
Wire.setClock(400000L);
writeRegister(0x6B, 0x01); // PWR_MGMT_1: wake up, clock from the X gyro's PLL
writeRegister(0x19, 7); // SMPLRT_DIV: 8 kHz / (7 + 1) = 1 kHz
writeRegister(0x1A, 0x00); // CONFIG: no low-pass filter
writeRegister(0x1B, 0x00); // GYRO_CONFIG: +-250 deg/s, 131 counts per deg/s
writeRegister(0x1C, 0x00); // ACCEL_CONFIG: +-2 g
delay(100);
while (!readSensor()) {
Serial.println(F("No MPU6050 at 0x68: check SDA, SCL and power"));
delay(1000);
}
// Start every estimate at the accelerometer's angle.
float roll = accelRoll(), pitch = accelPitch();
kalmanRoll.setAngle(roll);
kalmanPitch.setAngle(pitch);
gyroRoll = compRoll = kalRoll = roll;
gyroPitch = compPitch = kalPitch = pitch;
Serial.println(F("Kalman tilt: roll and pitch, accel gyro comp kalman"));
lastSample = micros();
}
void loop() {
if (micros() - lastSample < SAMPLE_US) return;
float dt = (micros() - lastSample) / 1000000.0;
lastSample = micros();
if (!readSensor()) return;
float roll = accelRoll();
float pitch = accelPitch();
float rollRate = gyroX / 131.0; // deg/s
float pitchRate = gyroY / 131.0;
// Roll runs -180..180; when it wraps round, start the filters again there
// rather than let them swing the long way.
if ((roll < -90 && kalRoll > 90) || (roll > 90 && kalRoll < -90)) {
kalmanRoll.setAngle(roll);
compRoll = kalRoll = gyroRoll = roll;
} else {
kalRoll = kalmanRoll.getAngle(roll, rollRate, dt);
}
kalPitch = kalmanPitch.getAngle(pitch, pitchRate, dt);
gyroRoll += rollRate * dt;
gyroPitch += pitchRate * dt;
compRoll = 0.93 * (compRoll + rollRate * dt) + 0.07 * roll;
compPitch = 0.93 * (compPitch + pitchRate * dt) + 0.07 * pitch;
bool level = fabs(kalRoll) <= LEVEL_WITHIN && fabs(kalPitch) <= LEVEL_WITHIN;
digitalWrite(GREEN_LED, level ? HIGH : LOW);
digitalWrite(RED_LED, level ? LOW : HIGH);
// Every fifth sample, 20 lines a second, for the Serial Plotter.
if (++printEvery < 5) return;
printEvery = 0;
Serial.print(F("accRoll:"));
Serial.print(roll);
Serial.print(F(" gyroRoll:"));
Serial.print(gyroRoll);
Serial.print(F(" compRoll:"));
Serial.print(compRoll);
Serial.print(F(" kalRoll:"));
Serial.print(kalRoll);
Serial.print(F(" accPitch:"));
Serial.print(pitch);
Serial.print(F(" gyroPitch:"));
Serial.print(gyroPitch);
Serial.print(F(" compPitch:"));
Serial.print(compPitch);
Serial.print(F(" kalPitch:"));
Serial.println(kalPitch);
}
Code walkthrough
The key functions of the sketch, and what each one does.
readSensor sketch.ino
Asks the MPU6050 for 14 bytes starting at register 0x3B. It joins each pair of bytes, high byte first, into the three accelerometer values and the two gyro values the sketch uses. It returns false if the sensor does not answer.
bool readSensor() {
Wire.beginTransmission(MPU_ADDR);
Wire.write(0x3B); // ACCEL_XOUT_H: accelerometer, temperature, gyroscope
if (Wire.endTransmission(false) != 0) return false;
if (Wire.requestFrom(MPU_ADDR, (uint8_t)14) != 14) return false;
uint8_t data[14];
for (uint8_t i = 0; i < 14; i++) data[i] = Wire.read();
// Each value is two bytes, high byte first. Bytes 6 and 7 are the
// temperature and 12 and 13 the gyro's Z axis, not used here.
accX = data[0] << 8 | data[1];
accY = data[2] << 8 | data[3];
accZ = data[4] << 8 | data[5];
gyroX = data[8] << 8 | data[9];
gyroY = data[10] << 8 | data[11];
return true;
}accelRoll sketch.ino
Gets the roll angle in degrees from gravity alone, using atan2 on the Y and Z readings. Its partner, accelPitch, does the same for pitch with the X axis.
// The accelerometer's angles in degrees. Pitch is kept to -90..90 so the
// two angles never fight each other near upside down.
float accelRoll() { return atan2(accY, accZ) * RAD_TO_DEG; }setup sketch.ino
Starts I2C, wakes the sensor and sets its ranges (gyro plus or minus 250 degrees per second, accelerometer plus or minus 2 g). It waits until the sensor answers, then starts every filter at the accelerometer's angle.
void setup() {
Serial.begin(115200);
pinMode(GREEN_LED, OUTPUT);
pinMode(RED_LED, OUTPUT);
Wire.begin();
Wire.setClock(400000L);
writeRegister(0x6B, 0x01); // PWR_MGMT_1: wake up, clock from the X gyro's PLL
writeRegister(0x19, 7); // SMPLRT_DIV: 8 kHz / (7 + 1) = 1 kHz
writeRegister(0x1A, 0x00); // CONFIG: no low-pass filter
writeRegister(0x1B, 0x00); // GYRO_CONFIG: +-250 deg/s, 131 counts per deg/s
writeRegister(0x1C, 0x00); // ACCEL_CONFIG: +-2 g
delay(100);
while (!readSensor()) {
Serial.println(F("No MPU6050 at 0x68: check SDA, SCL and power"));
delay(1000);
}
// Start every estimate at the accelerometer's angle.
float roll = accelRoll(), pitch = accelPitch();
kalmanRoll.setAngle(roll);
kalmanPitch.setAngle(pitch);
gyroRoll = compRoll = kalRoll = roll;
gyroPitch = compPitch = kalPitch = pitch;
Serial.println(F("Kalman tilt: roll and pitch, accel gyro comp kalman"));
lastSample = micros();
}loop sketch.ino
Every 10 ms it reads the sensor and updates the Kalman, gyro-only and complementary angles. It sets the LEDs by the LEVEL_WITHIN rule. Every fifth sample it prints all eight values for the plotter.
void loop() {
if (micros() - lastSample < SAMPLE_US) return;
float dt = (micros() - lastSample) / 1000000.0;
lastSample = micros();
if (!readSensor()) return;
float roll = accelRoll();
float pitch = accelPitch();
float rollRate = gyroX / 131.0; // deg/s
float pitchRate = gyroY / 131.0;
// Roll runs -180..180; when it wraps round, start the filters again there
// rather than let them swing the long way.
if ((roll < -90 && kalRoll > 90) || (roll > 90 && kalRoll < -90)) {
kalmanRoll.setAngle(roll);
compRoll = kalRoll = gyroRoll = roll;
} else {
kalRoll = kalmanRoll.getAngle(roll, rollRate, dt);
}
kalPitch = kalmanPitch.getAngle(pitch, pitchRate, dt);
gyroRoll += rollRate * dt;
gyroPitch += pitchRate * dt;
compRoll = 0.93 * (compRoll + rollRate * dt) + 0.07 * roll;
compPitch = 0.93 * (compPitch + pitchRate * dt) + 0.07 * pitch;
bool level = fabs(kalRoll) <= LEVEL_WITHIN && fabs(kalPitch) <= LEVEL_WITHIN;
digitalWrite(GREEN_LED, level ? HIGH : LOW);
// ... 22 more lines in the editorTroubleshooting
The usual mistakes with this circuit, and the fix for each.
- The Serial Monitor repeats No MPU6050 at 0x68 This project
Check SDA on A4, SCL on A5, 5 V and ground. The sketch waits until the sensor replies. - The plot looks garbled or has no lines This project
Set the Serial Plotter to 115200 baud to matchSerial.begin(115200). - The green LED never lights even when flat This project
Both angles must be withinLEVEL_WITHIN, which is 2 degrees. Widen it if you want a looser bubble level. - The LED is dim, burns out, or the pin reads wrong because the resistor value is off. Resistor
Read the color bands again. A 220 ohm to 330 ohm resistor suits a typical LED on 5 V. 10k is a common pull-up or pull-down value. Check with a multimeter if unsure. - A pull-up or pull-down resistor is missing, so the input floats and reads random values. Resistor
Tie the input to 5 V or ground through about 10k. Or use INPUT_PULLUP and wire the switch to ground. - The LED does not light, or it lights once and then dies. LED
LEDs have polarity. The longer leg (anode) goes toward the positive side. Always add a series resistor, about 220 ohms to 1 k ohm on a 5 V pin. - A blue or white LED stays dark on 3.3 V. LED
Blue and white LEDs drop about 3 V, so 3.3 V leaves almost nothing for the resistor. Use a 5 V supply, or a smaller resistor and a red or green LED. - The sensor is not found or reads all zeros. Accelerometer and gyroscope
Check SDA and SCL (A4 and A5 on an Uno), power, and ground. The I2C address is 0x68, or 0x69 if AD0 is high. The chip starts in sleep mode, so write 0 to the power register PWR_MGMT_1.
Questions and answers
What does the Kalman filter do here?
It tracks the angle and the gyro's bias. Each sample it predicts the angle from the gyro rate less the bias, then corrects toward the accelerometer's angle in proportion to how unsure the prediction is.
Why not use the accelerometer or the gyro alone?
The accelerometer's angle is right at rest, but every bump is acceleration too, so it is noisy. The gyro integral is smooth, but its errors add up and it drifts. On the plotter the gyro-only line ends about 3 degrees off after a drag.
What are Q_angle, Q_bias and R_measure?
How much the filter trusts each source. The library defaults are 0.001, 0.003 and 0.03. Raise R_measure to trust the accelerometer less; change them with setQangle(), setQbias() and setRmeasure().
How do the gyro readings become degrees per second?
At the +/-250 degrees per second range the MPU6050 gives 131 counts per degree per second, so the sketch divides the raw value by 131.0.
Change it and keep it
Open it in the editor, change the circuit or the code, and keep your version in a free account.