Lab 2: IMU

During this lab, I worked with the IMU.

IMU Setup

Artemis Connections

I connected the SparkFun 9DOF IMU to the Artemis Nano via a QWIIC connector. IMU connected to artemis

IMU Sanity Check

After installing the SparkFun 9DOF IMU Breakout_ICM 20948_Arduino Library via Arduino’s library manager, I ran the basic example to verify that the setup was working correctly. You should see something like below,

Scaled. Acc (mg) [  00254.88,  00059.08,  00973.14 ], Gyr (DPS) [ -00001.78, -00000.51, -00001.42 ], Mag (uT) [ -00021.45, -00027.90,  00062.55 ], Tmp (C) [  00034.21 ]

AD0_VAL

The AD0_VAL refers to the last bit of the I2C address of the IMU. It is by default set to 1 on the IMU, and thus ADO_VAL should be set to 1 to communicate. This value can be changed by closing the ADR jumper which sets it to 0. Also, the IMU seems to offer a AD0 pin which I think can be set by software when connected to a GPIO pin.

Understanding sensor values

Placing the IMU flat on a surface, Flat IMU

The accelerometer and gyro data should look something like

Scaled. Acc (mg) [ -00003.42, -00012.21,  01021.00 ], Gyr (DPS) [ -00001.12,  00001.79,  00000.08 ]

You can see that there is roughly 1g of acceleration due to gravity on the Z-axis.

Placing the IMU on its side as below, you should now see 1g on the y-axis. Side IMU

The gyro measures the angular velocity of the IMU in degrees per second (DPS). Rotating it around the Z-axis, the angular velocity about the z-axis will increase. Side IMU

Accelerometer

The accelerometer data can be used to compute the pitch and roll of the IMU via the following formulae, \(p = \text{atan2}(a_z, a_z), r = \text{atan2}(a_x, a_z)\)

Then,

I found that the accelerometer was fairly accurate within about 0.02 rad of the true answer. For 90 degrees pitch, I got 1.58 rad (the true value is 1.57). I got -1.56 for -90 degree pitch. For 90 and -90 roll, I got 1.56 and -1.56 respectively.

Noise in frequency spectrum

While the noise in my testing was fairly low, when running the RC car, there will be a lot of vibrational noise that will make readings noisy. To elimate this, I use a low pass filter.

Fourier Transform

To figure out which frequencies to keep, I obtained timestamped sensor readings from the IMU while I moved it around.

Roll FFT
Roll FFT
Pitch FFT
Pitch FFT

The graphs above show a peak around 0 Hz which is expected, and it shows that the pertinent signal has frequencies > 1.5 Hz. So, eliminating these frequencies should help combat sensor noise.

Low Pass Filter

Using a low pass filter, that’s defined by the following recursive relation, \(y' = \alpha * x + (1-\alpha) * y\) where \(x\) is the raw measurement, \(y\) is the previous filtered measurement and \(y'\) is the new one. Then, to eliminate frequencies greater than \(f_c = 1.5\), set \(\alpha = \frac{1}{1 + \frac{f_s}{2\pi f_c}}\) where \(f_c, f_s\) are the cutoff and sampling frequencies.

Gyroscope

I now use the Gyroscope to obtain roll, pitch and yaw data via dead reckoning. I use the following code for it,

float dt = (float) (times[c] - times[prev])/1000;
float dx = sensor->gyrX()*dt * DEGTORAD;
float dy = sensor->gyrY()*dt * DEGTORAD;
gyr_pitches[c] = gyr_pitches[prev] + dy;
gyr_rolls[c] = gyr_rolls[prev] + dx;
gyr_yaw[c] = gyr_yaw[prev] + sensor->gyrZ()*dt * DEGTORAD;

Using the gyro produces less noisy estimates, but since dead reckoning is used errors accumulate rapidly leading to bias. One can imagine using both measurements in combination to produce a more accurate and less noisy estimat.

Complementary filter

I used a complementary filter to try and get the best of both worlds with the two sensors. I found that the complementary filter was more noisy than just the gyroscopic estimates, but they were less biased.

Complementary The complementary filter is defined as follows,

acc_pitches[c] = lpf(acc_pitches[prev], pitch);
acc_rolls[c] = lpf(acc_rolls[prev], roll);
float dt = (float) (times[c] - times[prev])/1000;
float dx = sensor->gyrX()*dt * DEGTORAD;
float dy = sensor->gyrY()*dt * DEGTORAD;
gyr_pitches[c] = gyr_pitches[prev] + dy;
gyr_rolls[c] = gyr_rolls[prev] + dx;
gyr_yaw[c] = gyr_yaw[prev] + sensor->gyrZ()*dt * DEGTORAD;

comp_pitches[c] = (comp_pitches[prev] + dy)*(1-alpha) +  alpha * acc_pitches[c];
comp_rolls[c] = (comp_rolls[prev] + dx)*(1-alpha) +  alpha * acc_rolls[c];

Sample Data

Speed of Sampling

To improve the sampling speed, I removed all manual delays from the control loop, and every iteration checked whether the sensor data was ready, and if not I continued. During my testing I saw a sample rate of roughly a sample every 2.86 ms which is a rate of 372 samples per second. I created 7 float arrays that stored the filtered pitch, roll from the accelerometer, roll, pitch and yaw from gyro and roll, pitch for the complementary filter. I chose to use floats because the magnitude of the values is not very high, so float precision would suffice. I also had an unsigned int array for timestamps. With a length of 10000 each, it took about 320Kb of memory and can store ~26s of data.

Sending 5s of data over BLE

I used the following code to do this,

void get_data(ICM_20948_I2C *sensor)
{
  times[cnt % ARR_SIZE] = millis();
  float roll = atan2(sensor->accX(), sensor->accZ());
  float pitch = atan2(sensor->accY(), sensor->accZ());
  if (cnt > 0){
    int c = cnt % ARR_SIZE;
    int prev = (cnt - 1) % ARR_SIZE;
    acc_pitches[c] = lpf(acc_pitches[prev], pitch);
    acc_rolls[c] = lpf(acc_rolls[prev], roll);
    float dt = (float) (times[c] - times[prev])/1000;
    float dx = sensor->gyrX()*dt * DEGTORAD;
    float dy = sensor->gyrY()*dt * DEGTORAD;
    gyr_pitches[c] = gyr_pitches[prev] + dy;
    gyr_rolls[c] = gyr_rolls[prev] + dx;
    gyr_yaw[c] = gyr_yaw[prev] + sensor->gyrZ()*dt * DEGTORAD;
    pitches[c] = (pitches[prev] + dy)*(1-alpha) +  alpha * acc_pitches[c];
    rolls[c] = (rolls[prev] + dx)*(1-alpha) +  alpha * acc_rolls[c];
  }
  else{
    acc_pitches[cnt] = pitch;
    acc_rolls[cnt] = roll;
  }
  
}
void loop()
{
  // BLE.advertise();
  BLEDevice central = BLE.central();
    // If a central is connected to the peripheral
  if (central) {
    
      Serial.print("Connected to: ");
      Serial.println(central.address());

      // While central is connected
      while (central.connected()) {
          if (myICM.dataReady()){
            myICM.getAGMT(); 
            // read sensor data
            get_data(&myICM);
          }
          // Read data
          read_data();
          cnt++;
      }

      Serial.println("Disconnected");
  }
}

Stunt

I found the motors had very high torque which allowed the robot flip if you change direction suddenly. The video below demonstrates this Playing with RC car

Overall, I learnt a lot about how to combat a lot of the issues like noise and error compounding when using sensors in real life. It was cool to see how well simple exponential moving averages are at smoothing out a signal.