Understanding Sensor Noise and Data Filtering

Introduction

In this project, I learn about sensor noise and fitering of data extracted from MPU6050.

In a way, this project is a continuation of an earlier one where I integrated an MPU6050 with an Arduino Uno.

The Gimbal

The experiment deserved a better gimbal for measuring the results of the experiment. Here's a picture of the second version, which provides 3-degrees of freedom.

picture of gimbal version 2

Understanding Sensor Noise

Description

The code that I wrote for the previous project gathered accelerometer and gyroscope data at the X-Y-Z axes.

In this project, I'm adding the capability to measure total amplitude of the data (instead of X-Y-Z axes), plus the standard deviation.

I then create a simple data logger to understand noise characteristics.

The loop in the Arduino code performs the following operations:

  • Read samples of data from MPU6050 and convert the units
  • It accumulates the predetermined number of data samples and
  • Calls the function that perform data analysis
  • Resets the variables to start a new analysis

The data analysis in showResults reports the following information:

  • Calculates the standard deviation for the accelerometer and gyroscope data
  • Prints the accelerometer results
  • Prints the gyroscope results
  • Prints the gravity calculation calibration

Arduino Code

#include <Wire.h>

const int MPU_addr = 0x68;
int16_t AcX, AcY, AcZ, GyX, GyY, GyZ;

// Scaling factors
const float accel_scale = 16384.0;
const float gyro_scale = 131.0;

// Analysis variables
const int SAMPLE_SIZE = 50;
const int LOOP_DELAY = 50;  // in msec
const int BOARD_DELAY = 1000;  // in msec

int count = 0;
float sum_ax = 0, sum_ay = 0, sum_az = 0;
float sum_gx = 0, sum_gy = 0, sum_gz = 0;
float sum_amag = 0, sum_gmag = 0;
float sum_ax2 = 0, sum_ay2 = 0, sum_az2 = 0;
float sum_amag2 = 0, sum_gmag2 = 0;

void setup() {
  Serial.begin(9600);
  Wire.begin();
 
  // Wake up MPU
  Wire.beginTransmission(MPU_addr);
  Wire.write(0x6B);
  Wire.write(0);
  Wire.endTransmission(true);
 
  Serial.println("=== IMU Noise Test ===");
  Serial.println("Keep IMU still. Testing...");
  delay(BOARD_DELAY);  // wait for the board to be ready
}

void loop() {
  // Read IMU
  Wire.beginTransmission(MPU_addr);
  Wire.write(0x3B);
  Wire.endTransmission(false);
  Wire.requestFrom(MPU_addr, 14, true);
 
  AcX = Wire.read() << 8 | Wire.read();
  AcY = Wire.read() << 8 | Wire.read();
  AcZ = Wire.read() << 8 | Wire.read();
  Wire.read(); Wire.read(); // Skip temperature
  GyX = Wire.read() << 8 | Wire.read();
  GyY = Wire.read() << 8 | Wire.read();
  GyZ = Wire.read() << 8 | Wire.read();
 
  // Convert IMU data to physical units
  float ax = AcX / accel_scale;
  float ay = AcY / accel_scale;
  float az = AcZ / accel_scale;
  float gx = GyX / gyro_scale;
  float gy = GyY / gyro_scale;
  float gz = GyZ / gyro_scale;
 
  if (count < SAMPLE_SIZE) {
    /* We're going to analyze the data for every SAMPLE_SIZE samples.
       This here code accumulates the data that's received from the IMU
       and when the correct number of samples has been received, then
       then next loop runs the code in ELSE section, which prints the
       results of the analysis.
    */
    // Calculate magnitudes of accelerometer and gyroscope x-y-z- data
    float amag = sqrt(ax*ax + ay*ay + az*az);
    float gmag = sqrt(gx*gx + gy*gy + gz*gz);
   
    // Accumulate sums for mean
    sum_ax += ax; sum_ay += ay; sum_az += az;
    sum_gx += gx; sum_gy += gy; sum_gz += gz;
    sum_amag += amag; sum_gmag += gmag;
   
    // Accumulate sums of squares for std dev
    sum_ax2 += ax*ax; sum_ay2 += ay*ay; sum_az2 += az*az;
    sum_amag2 += amag*amag; sum_gmag2 += gmag*gmag;
   
    count++;
   
    if (count % 10 == 0) {
      Serial.print("Progress: ");
      Serial.print(count);
      Serial.print("/");
      Serial.println(SAMPLE_SIZE);
    }
  } else {
    // Calculate and display results
    showResults();
   
    // Reset for next test
    count = 0;
    sum_ax = sum_ay = sum_az = 0;
    sum_gx = sum_gy = sum_gz = 0;
    sum_amag = sum_gmag = 0;
    sum_ax2 = sum_ay2 = sum_az2 = 0;
    sum_amag2 = sum_gmag2 = 0;
  }
 
  delay(LOOP_DELAY);
}

void showResults() {
  Serial.println("\n=== NOISE ANALYSIS RESULTS ===");
 
  // Calculate means of data
  float avg_ax = sum_ax / SAMPLE_SIZE;
  float avg_ay = sum_ay / SAMPLE_SIZE;
  float avg_az = sum_az / SAMPLE_SIZE;
  float avg_amag = sum_amag / SAMPLE_SIZE;
  float avg_gmag = sum_gmag / SAMPLE_SIZE;
 
  // Calculate standard deviations using: sqrt((sum_x2 - n*mean^2)/(n-1))
  float std_ax = sqrt((sum_ax2 - SAMPLE_SIZE * avg_ax * avg_ax) / (SAMPLE_SIZE-1));
  float std_ay = sqrt((sum_ay2 - SAMPLE_SIZE * avg_ay * avg_ay) / (SAMPLE_SIZE-1));
  float std_az = sqrt((sum_az2 - SAMPLE_SIZE * avg_az * avg_az) / (SAMPLE_SIZE-1));
  float std_amag = sqrt((sum_amag2 - SAMPLE_SIZE * avg_amag * avg_amag) / (SAMPLE_SIZE-1));
  float std_gmag = sqrt((sum_gmag2 - SAMPLE_SIZE * avg_gmag * avg_gmag) / (SAMPLE_SIZE-1));
  float acc_err = abs(avg_amag-1.0);
 
  // Accelerometer results
  Serial.println("ACCELEROMETER:");
  Serial.print("X: "); Serial.print(avg_ax,3); Serial.print("g ±"); Serial.println(std_ax,4);
  Serial.print("Y: "); Serial.print(avg_ay,3); Serial.print("g ±"); Serial.println(std_ay,4);
  Serial.print("Z: "); Serial.print(avg_az,3); Serial.print("g ±"); Serial.println(std_az,4);
  Serial.print("Total: "); Serial.print(avg_amag,3); Serial.print("g ±"); Serial.print(std_amag,4);
  Serial.println(std_amag < 0.004 ? " [EXCELLENT]" : std_amag < 0.01 ? " [GOOD]" : " [POOR]");
 
  // Gyroscope results  
  Serial.println("\nGYROSCOPE:");
  Serial.print("Total: "); Serial.print(avg_gmag,2); Serial.print("°/s ±"); Serial.print(std_gmag,3);
  Serial.println(std_gmag < 0.033 ? " [EXCELLENT]" : std_gmag < 0.05 ? " [GOOD]" : " [POOR]");
 
  // Health check
  Serial.println("\nGRAVITY CHECK:");
  Serial.print("Measurement Error: ");
  Serial.print(abs(avg_amag-1.0), 3);
  Serial.println(acc_err < 0.02 ? " [EXCELLENT]" : acc_err < 0.05 ? " [GOOD]" : " [POOR]");


  Serial.println("\nSPECS: Accel < 0.004g, Gyro < 0.033°/s\n");
}

Output Data

Results when X, Y, and Z = 0, and stationary

=== NOISE ANALYSIS RESULTS ===
ACCELEROMETER:
X: -0.051g ±0.0030
Y: 0.043g ±0.0030
Z: 0.784g ±0.0043
Total: 0.787g ±0.0044 [GOOD]

GYROSCOPE:
Total: 0.81°/s ±0.156 [POOR]

GRAVITY CHECK:
Measurement Error: 0.213 [POOR]

SPECS: Accel < 0.004g, Gyro < 0.033°/s

Results with X = -30° and stationary:

=== NOISE ANALYSIS RESULTS ===
ACCELEROMETER:
X: 0.075g ±0.0032
Y: -0.446g ±0.0028
Z: 0.683g ±0.0046
Total: 0.819g ±0.0040 [GOOD]

GYROSCOPE:
Total: 0.84°/s ±0.122 [POOR]

GRAVITY CHECK:
Measurement Error: 0.181 [POOR]

SPECS: Accel < 0.004g, Gyro < 0.033°/s

Results with Y = -30° and stationary:

=== NOISE ANALYSIS RESULTS ===
ACCELEROMETER:
X: -0.397g ±0.0041
Y: 0.040g ±0.0032
Z: 0.686g ±0.0045
Total: 0.793g ±0.0046 [GOOD]

GYROSCOPE:
Total: 0.81°/s ±0.112 [POOR]

GRAVITY CHECK:
Measurement Error: 0.207 [POOR]

SPECS: Accel < 0.004g, Gyro < 0.033°/s

Serial Plotter

I made some minor adjustments to the code in showResults so I could plot the data using the Serial Plotter of the Arduino IDE. I turned on the Serial Plotter to see what happens to the data at different orientations:

But first, the code that used:

/* print these to use the Serial Plotter */
Serial.print(avg_amag); Serial.print(", ");
Serial.print(std_gmag);  Serial.print(", ");
Serial.println(acc_err);

Plot of the accelerometer data:

plot of accelerometer data

Plot of the gyroscope data:

plot of the gyroscope data

Plot of error in measuring gravity:

plot of gravity measurements

The plots confirm, as the printed observations, that:

  • Sensor data is noisy even when stationary
  • Accelerometer noise varies with orientation
  • Gyroscope data has bias (non-zero reading when stationary)

MPU6050 Data Filtering

Filter Types

The MPU6050's raw accelerometer and gyroscope data is inherently noisy, so filtering is essential for most applications. Here are the main filter types and their trade-offs:

Low-pass filters:

  • Advantages: Simple to implement, effective at removing high-frequency noise and vibrations
  • Disadvantages: Introduces lag/delay, can make the system feel sluggish for real-time applications
  • Best for: Smoothing data when some delay is acceptable, like slow-moving orientation tracking

High-pass filters:

  • Advantages: Removes drift and low-frequency bias, good for detecting sudden movements
  • Disadvantages: Can amplify high-frequency noise, removes useful low-frequency information
  • Best for: Motion detection, removing gravity component from accelerometer data

Complementary filter:

  • Advantages: Fast, lightweight, combines accelerometer and gyroscope strengths while minimizing their weaknesses
  • Disadvantages: Requires manual tuning of the mixing ratio, less optimal than more sophisticated methods
  • Best for: Real-time orientation estimation on resource-constrained systems like Arduino

Kalman filter:

  • Advantages: Theoretically optimal, adapts to changing conditions, provides uncertainty estimates
  • Disadvantages: Complex to implement and tune, computationally intensive, requires good understanding of system dynamics
  • Best for: High-precision applications where computational resources allow

Moving average:

  • Advantages: Extremely simple, no parameters to tune
  • Disadvantages: Equal weight to all samples in window, can be too aggressive or too weak
  • Best for: Quick noise reduction when sophistication isn't needed

Implementing Complementary Filter

#include <Wire.h>
#include <Adafruit_MPU6050.h>
#include <Adafruit_Sensor.h>

// Create an instance of the MPU6050 class called mpu
Adafruit_MPU6050 mpu;

// useful constants
const int SERIAL_BPS = 9600;
const int LOOP_DELAY = 10;

// Complementary filter variables
float dt = 0.01;  // Time step (10ms = 100Hz)
float alpha = 0.98;  // Filter coefficient (0.98 means 98% gyro, 2% accel)

// Angle estimates
float pitch = 0;
float roll = 0;

// Sensor event structures
sensors_event_t accel, gyro, temp;

// Values computed from sensor data in processRawData
float accel_pitch, accel_roll;
float gyro_pitch_rate, gyro_roll_rate;

// Timing
unsigned long last_time;

void setup() {
  Serial.begin(SERIAL_BPS);
 
  // Initialize MPU6050
  if (!mpu.begin()) {
    Serial.println("Failed to find MPU6050 chip");
    while (1) {
      delay(10);
    }
  }
  Serial.println("MPU6050 Found!");

  // Set accelerometer and gyroscope ranges
  mpu.setAccelerometerRange(MPU6050_RANGE_8_G);
  mpu.setGyroRange(MPU6050_RANGE_500_DEG);
  mpu.setFilterBandwidth(MPU6050_BAND_21_HZ);
 
  // Calibrate initial angles using accelerometer
  calibrateInitialAngles();
 
  last_time = millis();
}

void loop() {
  // Calculate time step
  unsigned long current_time = millis();
  dt = (current_time - last_time) / 1000.0;
  last_time = current_time;
 
  // Read sensor data
  mpu.getEvent(&accel, &gyro, &temp);
 
  // Process raw data
  processRawData();
 
  // Apply complementary filter
  complementaryFilter();
 
  // Print results
  printToPlot();
 
  delay(LOOP_DELAY);
}

void processRawData() {
  // Convert accelerometer values to angles (in degrees)
  // accel.acceleration values are in m/s²
  accel_pitch = atan2(accel.acceleration.y,
                     sqrt(accel.acceleration.x * accel.acceleration.x +
                          accel.acceleration.z * accel.acceleration.z)) * 180/PI;
 
  accel_roll = atan2(-accel.acceleration.x,
                    sqrt(accel.acceleration.y * accel.acceleration.y +
                         accel.acceleration.z * accel.acceleration.z)) * 180/PI;
 
  // Gyroscope values are in rad/s, convert to degrees/s
  gyro_pitch_rate = gyro.gyro.x * 180/PI;
  gyro_roll_rate = gyro.gyro.y * 180/PI;
}

void complementaryFilter() {
  // Complementary filter equation:
  // angle = alpha * (angle + gyro_rate * dt) + (1 - alpha) * accel_angle
 
  pitch = alpha * (pitch + gyro_pitch_rate * dt) + (1 - alpha) * accel_pitch;
  roll = alpha * (roll + gyro_roll_rate * dt) + (1 - alpha) * accel_roll;
}

void calibrateInitialAngles() {
  Serial.println("Calibrating... Keep sensor still");
 
  float sum_ax = 0, sum_ay = 0, sum_az = 0;
  int samples = 100;
 
  for (int i = 0; i < samples; i++) {
    mpu.getEvent(&accel, &gyro, &temp);
    sum_ax += accel.acceleration.x;
    sum_ay += accel.acceleration.y;
    sum_az += accel.acceleration.z;
    delay(20);
  }
 
  // Calculate average and initial angles
  float avg_ax = sum_ax / samples;
  float avg_ay = sum_ay / samples;
  float avg_az = sum_az / samples;
 
  pitch = atan2(avg_ay, sqrt(avg_ax*avg_ax + avg_az*avg_az)) * 180/PI;
  roll = atan2(-avg_ax, sqrt(avg_ay*avg_ay + avg_az*avg_az)) * 180/PI;
 
  Serial.println("Calibration complete");
}

void printResults() {
  // use this one instead of printToPlot if you plan to print the data
  // make sure to update the call in loop()
  Serial.print("Pitch: ");
  Serial.print(pitch, 2);
  Serial.print("°\tRoll: ");
  Serial.print(roll, 2);
  Serial.print("°\t");
  Serial.print("Accel P: ");
  Serial.print(accel_pitch, 2);
  Serial.print("°\tAccel R: ");
  Serial.print(accel_roll, 2);
  Serial.println("°");
}

void printToPlot() {
  // use this one instead of printResult if you plan to plot the data
  // make sure to update the call in loop()
  Serial.print(pitch, 2); Serial.print(", ");
  Serial.print(roll, 2); Serial.print(", ");
  Serial.print(accel_pitch, 2); Serial.print(", ");
  Serial.println(accel_roll, 2);
}

// Alternative implementation with adjustable parameters
void setFilterParameters(float new_alpha, float new_dt) {
  alpha = constrain(new_alpha, 0.0, 1.0);
  dt = new_dt;
}

// Function to get current angle estimates
float getPitch() {
  return pitch;
}

float getRoll() {
  return roll;
}

Plotting The Results

The above code yields a graph similar to the following:

plot of data and complementary filter

Where:

  • Value 1 is the pitch value computed by the complementary filter
  • Valie 2 is the roll value computed by the complementary filter.
  • Value 3 is the accel_pitch value computed directly from sensor accelerometer data
  • Value 4 is the accel_roll value computed dorectly from sensor accelerometer data

Varying The Alpha Parameter

I was planning to display multiple grapes to demonstrate the effects of using different values of alpha. But then, the test platform (gimbal) is manual and I cannot adequately reproduce the results. Thus, I updated the code to compare the results through a statistical analysis of the effects of alpha.

Static Gimbal

Alpha P Std Dev R Std Dev P Noise R Noise
0.75 0.07 0.04 0.021 0.017
0.85 0.07 0.05 0.014 0.012
0.95 0.06 0.04 0.005 0.004
0.98 nan 0.03 0.002 0.001

Dynamic Gimbal

Alpha P Std Dev R Std Dev P Noise R Noise
0.75 31.42 1.90 1.282 0.466
0.85 30.63 1.72 0.936 0.226
0.95 28.86 1.63 0.792 0.116
0.98 27.22 1.28 0.734 0.084