ICM-20948 9-Axis Sensor Part I

About this post

In this article, I introduce another 9-axis sensor, the ICM-20948. A few months ago I had already reported on its predecessor, the MPU-9250. The MPU-9250 is marked by the manufacturer as “EoL” (End of Life). But even though it will certainly be available for quite a while, it is time to take a closer look at the successor.

At first glance, the differences between the ICM-20948 and the MPU-9250 are not great. If you have worked with my library for the MPU-9250, you will quickly get along with my library for ICM-20948.

“Under the hood”, i.e. at register level, the differences are considerable. So, even if it seems otherwise – the creation of the library was more than a small modification of the MPU-9250 library.

Features and specifications of the ICM-20948

The ICM-20948 is called the 9-axis sensor because it combines three sensors, each covering three axes. It is:

  • Accelerometer,
  • Gyroscope,
  • and magnetometer.
  • In addition, the ICM-20948 is equipped with a thermometer.

Such combined sensors are often referred to as IMU for “Inertial Measurement Unit“.

I have already described how gyroscopes and an accelerometers work in my article about the MPU6050. The magnetometer works based on the Hall effect. You find a good explanation here.

 

ICM-20948 Module

The main technical specifications of the ICM-20948 are:

  • Power supply: 1.71 – 3.6 Volt
    • most modules do not have a voltage regulator
  • Communication via I2C, the addresses are:
    • AD0 set to LOW: 0x68
    • AD0 set to HIGH: 0x69
  • Communication via SPI (I have not implemented SPI in my lib)
  • Gyroscope:
    • Ranges: +/-250, +/-500, +/-1000, +/-2000   °/s
    • Data rate: 4.4 Hz – 9 kHz
  • Accelerometer:
    • Ranges: +/- 2, +/-4, +/-8, +/-16 g
    • Data rate: 4.5 Hz – 4.5 kHz
  • Magnetometer (AK09916):
    • Range: +/- 4900 μT
    • max. data rate: 100 Hz
  • FIFO (first in, first out) data storage: 512 / 4096 bytes
  • Interrupts (the ones I implemented): FIFO overflow, data ready, FSync and “wake-on-motion”
  • Integrated thermometer

The ICM-20948 allows the control of up to five additional sensors via an auxiliary I2C interface. The measured values are stored in the registers of the ICM-20948 and read from there.

The magnetometer is not really integrated into the ICM-20948. It has seperate registers and its own I2C address. You can only control it via the auxiliary interface just described. But don’t worry, you don’t have to deal with it because my library takes care for everything in the background.

Further information can be found in the technical data sheetof the ICM-20948 and in the data sheet of the magnetometer AK09916. You can read about the differences between the MPU-9250 and the ICM-20948 here.

Digital Motion Processor DMP

The ICM-20948 offers the possibility to process the data measured by it and other external sensors. That relieves the microcontroller. For this purpose, the ICM-20948 has the so-called Digital Motion ProcessorTM, DMP for short. However, the implementation of the DMP functions would be an effort for me that exceeds my capacities. Even without that, I’ve already invested several dozen hours in the development of the library. In addition, most hobby users would probably not use the DMP functions at all.

Controlling the ICM-20948

Wiring

Please keep in mind that most ICM-20948 modules can only tolerate up to 3.6 volts. This applies to all Pins. That’s why I put a levelshifter between the Arduino Nano and the ICM-20948. Alternatively, you can apply voltage dividers or – even easier – you use a microcontroller that runs at 3.3 volts.

Connect the address pin AD0 to VCC or GND, depending on which address you want to use. In some example sketches I also use the interrupt pin INT, which I attached to the Arduino pin 2.

Other pins, not used by me:

  • ADA and ACL are connections for other sensors that are controlled via the auxiliary I2C interface. I only implemented the auxiliary I2C interface for the magnetometer.
  • NCS is the chip select pin for SPI communication.
  • You can use FYSYNC as an external interrupt pin. I’ll have an example sketch for this later.
ICM-20948 with Arduino Nano and level converter
ICM-20948 with Arduino Nano and level converter

Introduction to the ICM20948_WE library

I tried some libraries, but I couldn’t really get excited about any of them. That’s why I wrote my own library, which you can find here on GitHub. Alternatively, you can install it directly using the library manager of the Arduino IDE.

The problem with the ICM-20948 is that it has an incredible number of setting options due to its 4 sensors plus I2C auxiliary interface. And although I haven’t implemented all the functions, my library includes fifty-nine public functions. To explain the use of these functions, I have provided the library with thirteen example sketches.

I will now walk you through the example sketches, which is a pretty dry read. Maybe just try them out and come back here if you don’t understand something.

Determine basic data with the ICM-20948

ICM20948_01_acceleration_data.ino

This first sketch is the one I go into most intensively. Much is repeated in the following sketches.

Before I start, a comment about the data type “xyzFloat”. This is a structure (struct) consisting of three float values. I use xyzFloat for all data that has an x, y, and z component.

#include <Wire.h>
#include <ICM20948_WE.h>
#define ICM20948_ADDR 0x68

/* There are several ways to create your ICM20948 object:
 * ICM20948_WE myIMU = ICM20948_WE()              -> uses Wire / I2C Address = 0x68
 * ICM20948_WE myIMU = ICM20948_WE(ICM20948_ADDR) -> uses Wire / ICM20948_ADDR
 * ICM20948_WE myIMU = ICM20948_WE(&wire2)        -> uses the TwoWire object wire2 / ICM20948_ADDR
 * ICM20948_WE myIMU = ICM20948_WE(&wire2, ICM20948_ADDR) -> all together
 * Successfully tested with two I2C busses on an ESP32
 */
ICM20948_WE myIMU = ICM20948_WE(ICM20948_ADDR);

void setup() {
  Wire.begin();
  Serial.begin(115200);
  while(!Serial) {}
  
  if(!myIMU.init()){
    Serial.println("ICM20948 does not respond");
  }
  else{
    Serial.println("ICM20948 is connected");
  }

  /*  This is a method to calibrate. You have to determine the minimum and maximum 
   *  raw acceleration values of the axes determined in the range +/- 2 g. 
   *  You call the function as follows: setAccOffsets(xMin,xMax,yMin,yMax,zMin,zMax);
   *  The parameters are floats. 
   *  The calibration changes the slope / ratio of raw acceleration vs g. The zero point 
   *  is set as (min + max)/2.
   */
  myIMU.setAccOffsets(-16330.0, 16450.0, -16600.0, 16180.0, -16640.0, 16560.0);
    
  /* The starting point, if you position the ICM20948 flat, is not necessarily 0g/0g/1g for x/y/z. 
   * The autoOffset function measures offset. It assumes your ICM20948 is positioned flat with its 
   * x,y-plane. The more you deviate from this, the less accurate will be your results.
   * It overwrites the zero points of setAccOffsets, but keeps the the correction of the slope.
   * The function also measures the offset of the gyroscope data. The gyroscope offset does not   
   * depend on the positioning.
   * This function needs to be called after setAccOffsets but before other other settings since 
   * it will overwrite settings!
   */
  Serial.println("Position your ICM20948 flat and don't move it - calibrating...");
  delay(1000);
  myIMU.autoOffsets();
  Serial.println("Done!"); 
  
  /* enables or disables the acceleration sensor, default: enabled */
  // myIMU.enableAcc(true);

  /*  ICM20948_ACC_RANGE_2G      2 g   (default)
   *  ICM20948_ACC_RANGE_4G      4 g
   *  ICM20948_ACC_RANGE_8G      8 g   
   *  ICM20948_ACC_RANGE_16G    16 g
   */
  myIMU.setAccRange(ICM20948_ACC_RANGE_2G);
  
  /*  Choose a level for the Digital Low Pass Filter or switch it off.  
   *  ICM20948_DLPF_0, ICM20948_DLPF_2, ...... ICM20948_DLPF_7, ICM20948_DLPF_OFF 
   *  
   *  DLPF       3dB Bandwidth [Hz]      Output Rate [Hz]
   *    0              246.0               1125/(1+ASRD) (default)
   *    1              246.0               1125/(1+ASRD)
   *    2              111.4               1125/(1+ASRD)
   *    3               50.4               1125/(1+ASRD)
   *    4               23.9               1125/(1+ASRD)
   *    5               11.5               1125/(1+ASRD)
   *    6                5.7               1125/(1+ASRD) 
   *    7              473.0               1125/(1+ASRD)
   *    OFF           1209.0               4500
   *    
   *    ASRD = Accelerometer Sample Rate Divider (0...4095)
   *    You achieve lowest noise using level 6  
   */
  myIMU.setAccDLPF(ICM20948_DLPF_6);    
  
  /*  Acceleration sample rate divider divides the output rate of the accelerometer.
   *  Sample rate = Basic sample rate / (1 + divider) 
   *  It can only be applied if the corresponding DLPF is not off!
   *  Divider is a number 0...4095 (different range compared to gyroscope)
   *  If sample rates are set for the accelerometer and the gyroscope, the gyroscope
   *  sample rate has priority.
   */
  myIMU.setAccSampleRateDivider(10);
}

void loop() {
  myIMU.readSensor();
  xyzFloat accRaw = myIMU.getAccRawValues();
  xyzFloat corrAccRaw = myIMU.getCorrectedAccRawValues();
  xyzFloat gVal = myIMU.getGValues();
  float resultantG = myIMU.getResultantG(gVal);

   
  Serial.println("Raw acceleration values (x,y,z):");
  Serial.print(accRaw.x);
  Serial.print("   ");
  Serial.print(accRaw.y);
  Serial.print("   ");
  Serial.println(accRaw.z);

  Serial.println("Corrected raw acceleration values (x,y,z):");
  Serial.print(corrAccRaw.x);
  Serial.print("   ");
  Serial.print(corrAccRaw.y);
  Serial.print("   ");
  Serial.println(corrAccRaw.z);

  Serial.println("g-values (x,y,z):");
  Serial.print(gVal.x);
  Serial.print("   ");
  Serial.print(gVal.y);
  Serial.print("   ");
  Serial.println(gVal.z);

  Serial.print("Resultant g: ");
  Serial.println(resultantG);
  Serial.println("*************************************");
 
  delay(1000);
}

 

Initialization and offsets

I have implemented several constructors. This allows you to pass the I2C address and/or the I2C object. The latter is interesting if, for example, you want to use both I2C interfaces of an ESP32.

The function init() first resets the ICM-20948 and writes default values to some registers. init() returns “false” if the ICM-20948 does not answer, otherwise true.

In the motionless state, only the acceleration due to gravity acts on the ICM-20948. If it is positioned flat (i.e. with its x,y plane), the g value should be zero for the x and y axes and one for the z axis. However, these values are more or less shifted. The function autoOffsets() measures the offset values which are subtracted from future measured values. However, it does not correct the slope.

autoOffsets() works only then reliably,

  • if the module lies flat with its x,y plane,
  • is not moved, and
  • the function is called as the first function in the setup (because it changes certain settings)

Alternatively, you can use the function setAccOffsets(). It leads to less good results regarding the zero point, but it is more accurate at larger angles. In addition, it corrects the slope, and you do not have to position the module flat at program start.

Other settings

The function setAccRange() sets the range for the acceleration measurements.

To control the data rate of the accelerometer, use the function setAccSampleRateDivider(divider).

\text{acceleration data rate}=\frac{1125}{1+divider}\;[\text{Hz}]\;\;\;\;\;\;\;\;\text{mit}\;\;divider = 0...4095

However, this only works if the digital low pass filter (DLPF) is activated. With setAccDLPF(level) you enable the DLPF and select the level. The parameter ICM20948_DLPF_OFF disables the DLPF. The higher the level, the lower the noise. Only level 7 does not fit in this series in this respect. The disadvantage of strong noise reduction is reduced response time. This means that when the acceleration changes, it takes a while for the MPU9250 to output the correct value.

With enableAcc(true/false) you can switch the accelerometer on or off.

The results

You can query the measured values with the following functions:

  • readSensor() reads the data registers in burst method (i.e. “in one go”) and writes the result to a buffer array.
    • Without a previous call of readSensor(), you will not get updated values.
  • getAccRawValues() provides the raw values of the acceleration in the ICM-20948 data register.
  • getCorrectedAccRawValues() reads the raw values and corrects them for the offsets.
  • getGValues() provides the acceleration values in g (based on the corrected raw data).
  • getResultantG(gValue) calculates the resulting acceleration from a g-value triple, i.e. the absolute value of the sum of the three vectors.
\text{resultantG}=\sqrt{(gValue.x)^2+(gValue.y)^2+(gValue.z)^2} \;\;\text{[g]}

If only gravity acts on the ICM-20948, the resultant should always be 1. With the resultant function, you can easily measure accelerations without having to align the movement to one axis. Simply subtract 1 to compensate for gravity.

The output of ICM20948_01_acceleration_data.ino

ICM-20948 - Output of ICM20948_01_acceleration_data.ino
Output of ICM20948_01_acceleration_data.ino

ICM20948_02_gyroscope_data.ino

Since you already know some functions, the explanation of the gyroscope sketch can be much shorter.

#include <Wire.h>
#include <ICM20948_WE.h>
#define ICM20948_ADDR 0x68

/* There are several ways to create your ICM20948 object:
 * ICM20948_WE myIMU = ICM20948_WE()              -> uses Wire / I2C Address = 0x68
 * ICM20948_WE myIMU = ICM20948_WE(ICM20948_ADDR) -> uses Wire / ICM20948_ADDR
 * ICM20948_WE myIMU = ICM20948_WE(&wire2)        -> uses the TwoWire object wire2 / ICM20948_ADDR
 * ICM20948_WE myIMU = ICM20948_WE(&wire2, ICM20948_ADDR) -> all together
 * Successfully tested with two I2C busses on an ESP32
 */
ICM20948_WE myIMU = ICM20948_WE(ICM20948_ADDR);

void setup() {
  Wire.begin();
  Serial.begin(115200);
  while(!Serial) {}
  
  if(!myIMU.init()){
    Serial.println("ICM20948 does not respond");
  }
  else{
    Serial.println("ICM20948 is connected");
  }

  Serial.println("Position your ICM20948 flat and don't move it - calibrating...");
  delay(1000);
  myIMU.autoOffsets();
  Serial.println("Done!"); 
  
  /*  The gyroscope data is not zero, even if you don't move the ICM20948. 
   *  To start at zero, you can apply offset values. These are the gyroscope raw values you obtain
   *  using the +/- 250 degrees/s range. 
   *  Use either autoOffsets or setGyrOffsets, not both.
   */
  //myIMU.setGyrOffsets(-115.0, 130.0, 105.0);
  
  /* enables or disables the gyroscope sensor, default: enabled */
  // myIMU.enableGyr(false);

  /*  ICM20948_GYRO_RANGE_250       250 degrees per second (default)
   *  ICM20948_GYRO_RANGE_500       500 degrees per second
   *  ICM20948_GYRO_RANGE_1000     1000 degrees per second
   *  ICM20948_GYRO_RANGE_2000     2000 degrees per second
   */
  myIMU.setGyrRange(ICM20948_GYRO_RANGE_250);
  
  /*  Choose a level for the Digital Low Pass Filter or switch it off. 
   *  ICM20948_DLPF_0, ICM20948_DLPF_2, ...... ICM20948_DLPF_7, ICM20948_DLPF_OFF 
   *  
   *  DLPF       3dB Bandwidth [Hz]      Output Rate [Hz]
   *    0              196.6               1125/(1+GSRD) 
   *    1              151.8               1125/(1+GSRD)
   *    2              119.5               1125/(1+GSRD)
   *    3               51.2               1125/(1+GSRD)
   *    4               23.9               1125/(1+GSRD)
   *    5               11.6               1125/(1+GSRD)
   *    6                5.7               1125/(1+GSRD) 
   *    7              361.4               1125/(1+GSRD)
   *    OFF          12106.0               9000
   *    
   *    GSRD = Gyroscope Sample Rate Divider (0...255)
   *    You achieve lowest noise using level 6  
   */
  myIMU.setGyrDLPF(ICM20948_DLPF_6);  
  
  /*  Gyroscope sample rate divider divides the output rate of the gyroscope.
   *  Sample rate = Basic sample rate / (1 + divider) 
   *  It can only be applied if the corresponding DLPF is not OFF!
   *  Divider is a number 0...255
   *  If sample rates are set for the accelerometer and the gyroscope, the gyroscope
   *  sample rate has priority.
   */
  //myIMU.setGyrSampleRateDivider(10);
}

void loop() {
  myIMU.readSensor();
  xyzFloat gyrRaw = myIMU.getCorrectedGyrRawValues();
  xyzFloat gyr = myIMU.getGyrValues();
    
  Serial.println("Raw gyroscope values (x,y,z):");
  Serial.print(gyrRaw.x);
  Serial.print("   ");
  Serial.print(gyrRaw.y);
  Serial.print("   ");
  Serial.println(gyrRaw.z);

  Serial.println("Gyroscope values (x,y,z):");
  Serial.print(gyr.x);
  Serial.print("   ");
  Serial.print(gyr.y);
  Serial.print("   ");
  Serial.println(gyr.z);
  Serial.println();

  delay(500);
}

 

Again, you can use the autoOffsets() function. The gyroscope should indicate zero or at least fluctuate around zero for all axes in the motionless state. However, you will notice some offset that is independent of the orientation. Alternatively, apply the values determined in the measuring range +/-250 °/s in setGyrOffsets(). I’ll come back to that in the section of the calibration sketch.

You activate the low-pass filter (DLPF) with setGyrDLPF(value) and set the level. Here, too, a stronger filter leads to reduced reaction speed. Level 7 is an exception in this respect. ICM20948_DLPF_OFF disables the DLPF.

Other features:

  • setGyrSampleRateDivider() works similar to the accelerometer.
  • setGyrRange() defines the measuring range.
  • enableGyr(true/false) enables or disables the gyroscope.
  • getGyrRawValues() returns the currently available raw data.
  • getCorrectedGyrRawValues() subtracts the offsets from the raw data and returns the corrected data.
  • getGyrValues() provides the gyroscope data in degrees/second, based on the corrected raw data.

The output of ICM20948_02_gyroscope_data.ino

ICM-20948 - Edition of ICM20948_02_gyroscope_data.ino
Output of ICM20948_02_gyroscope_data.ino

ICM20948_03_magnetometer.ino

The magnetometer (AK09916) behaves like a separate component. It has its own I2C address, own registers and must therefore be initialized separately. If you apply an I2C scanner to the ICM-20948, you won’t detect the magnetometer. The magnetometer is not directly accessible, it is behind the ICM-20948, so to speak.  You control it via the auxiliary I2C interface described above, which is provided by the ICM-20948. However, you won’t notice any of this, as the library manages everything in the background.

#include <Wire.h>
#include <ICM20948_WE.h>
#define ICM20948_ADDR 0x68

/* There are several ways to create your ICM20948 object:
 * ICM20948_WE myIMU = ICM20948_WE()              -> uses Wire / I2C Address = 0x68
 * ICM20948_WE myIMU = ICM20948_WE(ICM20948_ADDR) -> uses Wire / ICM20948_ADDR
 * ICM20948_WE myIMU = ICM20948_WE(&wire2)        -> uses the TwoWire object wire2 / ICM20948_ADDR
 * ICM20948_WE myIMU = ICM20948_WE(&wire2, ICM20948_ADDR) -> all together
 * Successfully tested with two I2C busses on an ESP32
 */
ICM20948_WE myIMU = ICM20948_WE(ICM20948_ADDR);

void setup() {
  Wire.begin();
  Serial.begin(115200);
  while (!Serial) {}

  if (!myIMU.init()) {
    Serial.println("ICM20948 does not respond");
  }
  else {
    Serial.println("ICM20948 is connected");
  }

  if (!myIMU.initMagnetometer()) {
    Serial.println("Magnetometer does not respond");
  }
  else {
    Serial.println("Magnetometer is connected");
  }

  /* You can set the following modes for the magnetometer:
   * AK09916_PWR_DOWN          Power down to save energy
   * AK09916_TRIGGER_MODE      Measurements on request, a measurement is triggered by 
   *                           calling setMagOpMode(AK09916_TRIGGER_MODE)
   * AK09916_CONT_MODE_10HZ    Continuous measurements, 10 Hz rate
   * AK09916_CONT_MODE_20HZ    Continuous measurements, 20 Hz rate
   * AK09916_CONT_MODE_50HZ    Continuous measurements, 50 Hz rate
   * AK09916_CONT_MODE_100HZ   Continuous measurements, 100 Hz rate (default)
   */
  myIMU.setMagOpMode(AK09916_CONT_MODE_20HZ);
}

void loop() {
  myIMU.readSensor();
  xyzFloat magValue = myIMU.getMagValues(); // returns magnetic flux density [µT]

  Serial.println("Magnetometer Data in µTesla: ");
  Serial.print(magValue.x);
  Serial.print("   ");
  Serial.print(magValue.y);
  Serial.print("   ");
  Serial.println(magValue.z);

  delay(1000);
}

 

Magnetometer settings

The magnetometer has a power-down mode, a trigger mode and four continuous modes. The continuous modes differ in data rate, namely 10, 20, 40 or 100 Hz. The mode is set with setMagOpMode().

You retrieve the measured values with getMagValues(). The result is output in microtesla. The function returns the values that are currently in the data buffer. Before that, you have to call readSensor() to update the data buffer with the latest sensor data.

In trigger mode, a measurement is initiated with setMagOpMode(AK09916_TRIGGER_MODE). If you choose this option, you must give the magnetometer enough time to perform the measurement and transmit the data to the ICM-20948 data registers.

The output of ICM20948_03_magnetometer.ino

For the following output, I rotated the ICM-20948 flat in its x,y plane:

ICM20948 - Output of ICM20948_03_magnetometer.ino
Output of ICM20948_03_magnetometer.ino

In Central Europe, the magnetic flux density of the Earth’s magnetic field is about 20 microns in the horizontal and around 44 µT in the vertical (source: German Wikipedia). Accordingly, the difference between the maximum and minimum value for rotation in the horizontal or vertical should be 40 and 88 μT respectively. You probably won’t measure that values because:

  • Again, there are offsets.
  • You are probably measuring indoors, and depending on the construction of the building, the Earth’s magnetic field is shielded.
  • When measuring on a breadboard, the jumper cables and metal inside the breadboard will influence the measurements.

ICM20948_04_calibration.ino

This sketch is designed to help you determine the offsets for the accelerometer and gyroscope. For this purpose, the lowest measuring range and the maximum low-pass filter are set first. This ensures high resolution and low noise.

For the offset determination of the accelerometer, turn the ICM-20948 slowly (!) around its axes and note the maximum and minimum raw values. It is best to have your elbows on the table when doing this, as any shaking will result in additional acceleration. You then use this data for the setAccOffsets() function. Internally, the offsets are determined from this. When you change the measuring range, the offsets are automatically adjusted. With this method you are no longer dependent on the level orientation of the ICM-20948 at program start. For the determination of precise small angles, however, I still recommend the autoOffsets() function.

For the gyroscope offsets you do not need to rotate the ICM-20948 because the offset is independent of the inclination. You apply the values for the x-, y- and z-axis as parameters in setGyrOffsets().

#include <Wire.h>
#include <ICM20948_WE.h>
#define ICM20948_ADDR 0x68

/* There are several ways to create your ICM20948 object:
 * ICM20948_WE myIMU = ICM20948_WE()              -> uses Wire / I2C Address = 0x68
 * ICM20948_WE myIMU = ICM20948_WE(ICM20948_ADDR) -> uses Wire / ICM20948_ADDR
 * ICM20948_WE myIMU = ICM20948_WE(&wire2)        -> uses the TwoWire object wire2 / ICM20948_ADDR
 * ICM20948_WE myIMU = ICM20948_WE(&wire2, ICM20948_ADDR) -> all together
 * Successfully tested with two I2C busses on an ESP32
 */
ICM20948_WE myIMU = ICM20948_WE(ICM20948_ADDR);

void setup() {
  Wire.begin();
  Serial.begin(115200);
  while(!Serial) {}
  
  if(!myIMU.init()){
    Serial.println("ICM20948 does not respond");
  }
  else{
    Serial.println("ICM20948 is connected");
  }

  /*  This is a method to calibrate. You have to determine the minimum and maximum 
   *  raw acceleration values of the axes determined in the range +/- 2 g. 
   *  You call the function as follows: setAccOffsets(xMin,xMax,yMin,yMax,zMin,zMax);
   *  The parameters are floats. 
   *  The calibration changes the slope / ratio of raw accleration vs g. The zero point is 
   *  set as (min + max)/2.
   */
  myIMU.setAccOffsets(-16330.0, 16450.0, -16600.0, 16180.0, -16520.0, 16690.0);
    
  /*  The gyroscope data is not zero, even if you don't move the ICM20948. 
   *  To start at zero, you can apply offset values. These are the gyroscope raw values you obtain
   *  using the +/- 250 degrees/s range. 
   *  Use either autoOffset or setGyrOffsets, not both.
   */
  myIMU.setGyrOffsets(-115.0, 130.0, 105.0);
    
  /*  ICM20948_ACC_RANGE_2G      2 g   (default)
   *  ICM20948_ACC_RANGE_4G      4 g
   *  ICM20948_ACC_RANGE_8G      8 g   
   *  ICM20948_ACC_RANGE_16G    16 g
   */
  myIMU.setAccRange(ICM20948_ACC_RANGE_2G); // highest res for calibration
  
  /*  Choose a level for the Digital Low Pass Filter or switch it off.  
   *  ICM20948_DLPF_0, ICM20948_DLPF_2, ...... ICM20948_DLPF_7, ICM20948_DLPF_OFF 
   *  
   *  DLPF       3dB Bandwidth [Hz]      Output Rate [Hz]
   *    0              246.0               1125/(1+ASRD) 
   *    1              246.0               1125/(1+ASRD)
   *    2              111.4               1125/(1+ASRD)
   *    3               50.4               1125/(1+ASRD)
   *    4               23.9               1125/(1+ASRD)
   *    5               11.5               1125/(1+ASRD)
   *    6                5.7               1125/(1+ASRD) 
   *    7              473.0               1125/(1+ASRD)
   *    OFF           1209.0               4500
   *    
   *    ASRD = Accelerometer Sample Rate Divider (0...4095)
   *    You achieve lowest noise using level 6  
   */
  myIMU.setAccDLPF(ICM20948_DLPF_6); // lowest noise for calibration   
  
  /*  ICM20948_GYRO_RANGE_250       250 degrees per second (default)
   *  ICM20948_GYRO_RANGE_500       500 degrees per second
   *  ICM20948_GYRO_RANGE_1000     1000 degrees per second
   *  ICM20948_GYRO_RANGE_2000     2000 degrees per second
   */
  myIMU.setGyrRange(ICM20948_GYRO_RANGE_250); //highest resolution for calibration
  
  /*  Choose a level for the Digital Low Pass Filter or switch it off. 
   *  ICM20948_DLPF_0, ICM20948_DLPF_2, ...... ICM20948_DLPF_7, ICM20948_DLPF_OFF 
   *  
   *  DLPF       3dB Bandwidth [Hz]      Output Rate [Hz]
   *    0              196.6               1125/(1+GSRD) 
   *    1              151.8               1125/(1+GSRD)
   *    2              119.5               1125/(1+GSRD)
   *    3               51.2               1125/(1+GSRD)
   *    4               23.9               1125/(1+GSRD)
   *    5               11.6               1125/(1+GSRD)
   *    6                5.7               1125/(1+GSRD) 
   *    7              361.4               1125/(1+GSRD)
   *    OFF          12106.0               9000
   *    
   *    GSRD = Gyroscope Sample Rate Divider (0...255)
   *    You achieve lowest noise using level 6  
   */
  myIMU.setGyrDLPF(ICM20948_DLPF_6); // lowest noise for calibration
  
  /*  Choose a level for the Digital Low Pass Filter. 
   *  ICM20948_DLPF_0, ICM20948_DLPF_2, ...... ICM20948_DLPF_7, ICM20948_DLPF_OFF 
   *  
   *  DLPF          Bandwidth [Hz]      Output Rate [Hz]
   *    0             7932.0                    9
   *    1              217.9                 1125
   *    2              123.5                 1125
   *    3               65.9                 1125
   *    4               34.1                 1125
   *    5               17.3                 1125
   *    6                8.8                 1125
   *    7             7932.0                    9
   *                 
   *    
   *    GSRD = Gyroscope Sample Rate Divider (0...255)
   *    You achieve lowest noise using level 6  
   */
  myIMU.setTempDLPF(ICM20948_DLPF_6); // lowest noise for calibration
}

void loop() {
  myIMU.readSensor();
  xyzFloat accRaw;
  xyzFloat gyrRaw;
  xyzFloat corrAccRaw;
  xyzFloat corrGyrRaw;
  accRaw = myIMU.getAccRawValues();
  gyrRaw = myIMU.getGyrRawValues();
  corrAccRaw = myIMU.getCorrectedAccRawValues();
  corrGyrRaw = myIMU.getCorrectedGyrRawValues();
  xyzFloat gVal = myIMU.getGValues();
  
  Serial.println("Acceleration raw values without offset:");
  Serial.print(accRaw.x);
  Serial.print("   ");
  Serial.print(accRaw.y);
  Serial.print("   ");
  Serial.println(accRaw.z);

  Serial.println("Gyroscope raw values without offset:");
  Serial.print(gyrRaw.x);
  Serial.print("   ");
  Serial.print(gyrRaw.y);
  Serial.print("   ");
  Serial.println(gyrRaw.z);

  Serial.println("Acceleration raw values with offset:");
  Serial.print(corrAccRaw.x);
  Serial.print("   ");
  Serial.print(corrAccRaw.y);
  Serial.print("   ");
  Serial.println(corrAccRaw.z);

  Serial.println("Gyroscope raw values with offset:");
  Serial.print(corrGyrRaw.x);
  Serial.print("   ");
  Serial.print(corrGyrRaw.y);
  Serial.print("   ");
  Serial.println(corrGyrRaw.z);

  Serial.println("g-values, based on corrected raws (x,y,z):");
  Serial.print(gVal.x);
  Serial.print("   ");
  Serial.print(gVal.y);
  Serial.print("   ");
  Serial.println(gVal.z);


  Serial.println("********************************************");

  delay(500);
}

 

All together: ICM20948_05_acc_gyr_temp_mag_data.ino

Using this sketch, you can determine the acceleration, gyroscope values, magnetometer values and temperature. You query the temperature with getTemperature(). I have already explained all other functions. You can find the sketch in the examples.

The thermometer is not so much used to measure the ambient temperature as to track the temperature in the ICM-20948. It is higher than the room temperature and increases in operation depending on the measurement conditions.

The output of ICM20948_05_acc_gyr_temp_mag_data.ino

The output of ICM20948_05_acc_gyr_temp_mag_data.ino
The output of ICM20948_05_acc_gyr_temp_mag_data.ino

How to measure angles with the ICM-20948

You can use the (earth) acceleration data to calculate tilt angles. You have to make sure that no additional acceleration acts on the ICM-20948. I have implemented two methods for this.

ICM20948_06_angles_and_orientation.ino

With the method presented in this sketch, the angle α between the axes and the horizontal is calculated quite simply from the arcussine of the acceleration value.

\alpha=\arcsin(\text{g-Wert})

For small angles this works perfectly, at larger angles the error increases. Why this is the case, I explained in my article about the MMA7361. Up to 60°, the deviation in my experiments was less than one degree.

For this sketch, I recommend to use autoOffset(). With this function, you start at an angle of 0° for the x- and y-axis. For the z-axis you get a starting value around 90°. Since this is a large angle, the z-axis angle fluctuates strongly. For the tilt angles, you will only get reasonable values if you position the ICM-20948 flat for calibration.

#include <Wire.h>
#include <ICM20948_WE.h>
#define ICM20948_ADDR 0x68

/* There are several ways to create your ICM20948 object:
 * ICM20948_WE myIMU = ICM20948_WE()              -> uses Wire / I2C Address = 0x68
 * ICM20948_WE myIMU = ICM20948_WE(ICM20948_ADDR) -> uses Wire / ICM20948_ADDR
 * ICM20948_WE myIMU = ICM20948_WE(&wire2)        -> uses the TwoWire object wire2 / ICM20948_ADDR
 * ICM20948_WE myIMU = ICM20948_WE(&wire2, ICM20948_ADDR) -> all together
 * Successfully tested with two I2C busses on an ESP32
 */
ICM20948_WE myIMU = ICM20948_WE(ICM20948_ADDR);

void setup() {
  Wire.begin();
  Serial.begin(115200);
  while(!Serial) {}
  
  if(!myIMU.init()){
    Serial.println("ICM20948 does not respond");
  }
  else{
    Serial.println("ICM20948 is connected");
  }

  /*  This is a method to calibrate. You have to determine the minimum and maximum 
   *  raw acceleration values of the axes determined in the range +/- 2 g. 
   *  You call the function as follows: setAccOffsets(xMin,xMax,yMin,yMax,zMin,zMax);
   *  The parameters are floats. 
   *  The calibration changes the slope / ratio of raw acceleration vs g. The zero point is 
   *  set as (min + max)/2.
   */
  myIMU.setAccOffsets(-16330.0, 16450.0, -16600.0, 16180.0, -16640.0, 16560.0);
    
  /* The starting point, if you position the ICM20948 flat, is not necessarily 0g/0g/1g for x/y/z. 
   * The autoOffset function measures offset. It assumes your ICM20948 is positioned flat with its 
   * x,y-plane. The more you deviate from this, the less accurate will be your results.
   * It overwrites the zero points of setAccOffsets, but keeps the the correction of the slope.
   * The function also measures the offset of the gyroscope data. The gyroscope offset does not   
   * depend on the positioning.
   * This function needs to be called after setAccOffsets but before other other settings since 
   * it will overwrite settings!
   */
  Serial.println("Position your ICM20948 flat and don't move it - calibrating...");
  delay(1000);
  myIMU.autoOffsets();
  Serial.println("Done!"); 
  
  /* enables or disables the acceleration sensor, default: enabled */
  // myIMU.enableAcc(true);

  /*  ICM20948_ACC_RANGE_2G      2 g   (default)
   *  ICM20948_ACC_RANGE_4G      4 g
   *  ICM20948_ACC_RANGE_8G      8 g   
   *  ICM20948_ACC_RANGE_16G    16 g
   */
  myIMU.setAccRange(ICM20948_ACC_RANGE_2G);
  
  /*  Choose a level for the Digital Low Pass Filter or switch it off.  
   *  ICM20948_DLPF_0, ICM20948_DLPF_2, ...... ICM20948_DLPF_7, ICM20948_DLPF_OFF 
   *  
   *  DLPF       3dB Bandwidth [Hz]      Output Rate [Hz]
   *    0              246.0               1125/(1+ASRD) 
   *    1              246.0               1125/(1+ASRD)
   *    2              111.4               1125/(1+ASRD)
   *    3               50.4               1125/(1+ASRD)
   *    4               23.9               1125/(1+ASRD)
   *    5               11.5               1125/(1+ASRD)
   *    6                5.7               1125/(1+ASRD) 
   *    7              473.0               1125/(1+ASRD)
   *    OFF           1209.0               4500
   *    
   *    ASRD = Accelerometer Sample Rate Divider (0...4095)
   *    You achieve lowest noise using level 6  
   */
  myIMU.setAccDLPF(ICM20948_DLPF_6);    
  
  /*  Acceleration sample rate divider divides the output rate of the accelerometer.
   *  Sample rate = Basic sample rate / (1 + divider) 
   *  It can only be applied if the corresponding DLPF is not off!
   *  Divider is a number 0...4095 (different range compared to gyroscope)
   *  If sample rates are set for the accelerometer and the gyroscope, the gyroscope
   *  sample rate has priority.
   */
  myIMU.setAccSampleRateDivider(10);
}

void loop() {
  myIMU.readSensor();
  
  xyzFloat gValue = myIMU.getGValues();
  xyzFloat angle = myIMU.getAngles();
  
/* For g-values the corrected raws are used */
  Serial.print("g-x      = ");
  Serial.print(gValue.x);
  Serial.print("  |  g-y      = ");
  Serial.print(gValue.y);
  Serial.print("  |  g-z      = ");
  Serial.println(gValue.z);

/* Angles are also based on the corrected raws. Angles are simply calculated by
   angle = arcsin(g Value) */
  Serial.print("Angle x  = ");
  Serial.print(angle.x);
  Serial.print("  |  Angle y  = ");
  Serial.print(angle.y);
  Serial.print("  |  Angle z  = ");
  Serial.println(angle.z);

  Serial.print("Orientation of the module: ");
  Serial.println(myIMU.getOrientationAsString());

  delay(1000);
}

 

The following functions are added here:

  • getAngles() returns the angle of the x, y, and z axes to the horizontal in degrees.
    • g-values above 1 are truncated to 1 because the arc sine function is not defined for larger values.
  • getOrientationAsString() indicates which axis has the largest positive angle.
    • Possible return values are: x up, x down, y up, y down, z up, z down.
    • An alternative is getOrientation(). The return value is an enum (ICM20948_orientation). For the definition look in ICM20948_WE.h.

Output of ICM20948_06_angles_and_orientation.ino

For the following output I have rotated the module around the y-axis, i.e. I have erected the x-axis.

Output of ICM20948_06_angles_and_orientation.ino
Output of ICM20948_06_angles_and_orientation.ino

ICM20948_07_pitch_and_roll.ino

This method uses multiple axes to calculate the angles. As a result, the values are more accurate at large angles than with the simple arc sine method. On the other hand, at small angles, the latter method is preferable. To delineate the method, I used the nomenclature of other libraries and referred to the x-axis tilt angle as “pitch” and the y-axis tilt angle as “roll angle”.

pitch\; angle= \arctan \left(\frac{g_x}{\sqrt{\left|g_x\cdot g_y +g_z^2\right|}}\right)
roll\;angle = \arctan\left( \frac{g_y}{g_z} \right)

For comparison, I use both methods in this sketch. Try it out and choose what suits you more.

#include <Wire.h>
#include <ICM20948_WE.h>
#define ICM20948_ADDR 0x68

/* There are several ways to create your ICM20948 object:
 * ICM20948_WE myIMU = ICM20948_WE()              -> uses Wire / I2C Address = 0x68
 * ICM20948_WE myIMU = ICM20948_WE(ICM20948_ADDR) -> uses Wire / ICM20948_ADDR
 * ICM20948_WE myIMU = ICM20948_WE(&wire2)        -> uses the TwoWire object wire2 / ICM20948_ADDR
 * ICM20948_WE myIMU = ICM20948_WE(&wire2, ICM20948_ADDR) -> all together
 * Successfully tested with two I2C busses on an ESP32
 */
ICM20948_WE myIMU = ICM20948_WE(ICM20948_ADDR);

void setup() {
  Wire.begin();
  Serial.begin(115200);
  while(!Serial) {}
  
  if(!myIMU.init()){
    Serial.println("ICM20948 does not respond");
  }
  else{
    Serial.println("ICM20948 is connected");
  }

  /*  This is a method to calibrate. You have to determine the minimum and maximum 
   *  raw acceleration values of the axes determined in the range +/- 2 g. 
   *  You call the function as follows: setAccOffsets(xMin,xMax,yMin,yMax,zMin,zMax);
   *  The parameters are floats. 
   *  The calibration changes the slope / ratio of raw acceleration vs g. The zero point 
   *  is set as (min + max)/2.
   */
  //myIMU.setAccOffsets(-16330.0, 16450.0, -16600.0, 16180.0, -16520.0, 16690.0);
    
  /* The starting point, if you position the ICM20948 flat, is not necessarily 0g/0g/1g for x/y/z. 
   * The autoOffset function measures offset. It assumes your ICM20948 is positioned flat with its 
   * x,y-plane. The more you deviate from this, the less accurate will be your results.
   * It overwrites the zero points of setAccOffsets, but keeps the the correction of the slope.
   * The function also measures the offset of the gyroscope data. The gyroscope offset does not   
   * depend on the positioning.
   * This function needs to be called after setAccsOffsets but before other other settings since it will 
   * overwrite settings!
   */
  Serial.println("Position your ICM20948 flat and don't move it - calibrating...");
  delay(1000);
  myIMU.autoOffsets();
  Serial.println("Done!"); 
  
  /* enables or disables the acceleration sensor, dafault: enabled */
  // myIMU.enableAcc(true);

  /*  ICM20948_ACC_RANGE_2G      2 g   (default)
   *  ICM20948_ACC_RANGE_4G      4 g
   *  ICM20948_ACC_RANGE_8G      8 g   
   *  ICM20948_ACC_RANGE_16G    16 g
   */
  myIMU.setAccRange(ICM20948_ACC_RANGE_2G);
  
  /*  Choose a level for the Digital Low Pass Filter or switch it off.  
   *  ICM20948_DLPF_0, ICM20948_DLPF_2, ...... ICM20948_DLPF_7, ICM20948_DLPF_OFF 
   *  
   *  DLPF       3dB Bandwidth [Hz]      Output Rate [Hz]
   *    0              246.0               1125/(1+ASRD) 
   *    1              246.0               1125/(1+ASRD)
   *    2              111.4               1125/(1+ASRD)
   *    3               50.4               1125/(1+ASRD)
   *    4               23.9               1125/(1+ASRD)
   *    5               11.5               1125/(1+ASRD)
   *    6                5.7               1125/(1+ASRD) 
   *    7              473.0               1125/(1+ASRD)
   *    OFF           1209.0               4500
   *    
   *    ASRD = Accelerometer Sample Rate Divider (0...4095)
   *    You achieve lowest noise using level 6  
   */
  myIMU.setAccDLPF(ICM20948_DLPF_6);    
  
  /*  Acceleration sample rate divider divides the output rate of the accelerometer.
   *  Sample rate = Basic sample rate / (1 + divider) 
   *  It can only be applied if the corresponding DLPF is not off!
   *  Divider is a number 0...4095 (different range compared to gyroscope)
   *  If sample rates are set for the accelerometer and the gyroscope, the gyroscope
   *  sample rate has priority.
   */
  //myIMU.setAccSampleRateDivider(10);
}

void loop() {
  myIMU.readSensor();
  xyzFloat gValue = myIMU.getGValues();
  xyzFloat angle = myIMU.getAngles();
  float pitch = myIMU.getPitch();
  float roll  = myIMU.getRoll();
 
  Serial.println("G values (x,y,z):");
  Serial.print(gValue.x);
  Serial.print("   ");
  Serial.print(gValue.y);
  Serial.print("   ");
  Serial.println(gValue.z);
  Serial.println("Angles (x,y,z):");
  Serial.print(angle.x);
  Serial.print("   ");
  Serial.print(angle.y);
  Serial.print("   ");
  Serial.println(angle.z);

  Serial.print("Pitch = "); 
  Serial.print(pitch); 
  Serial.print("  |  Roll = "); 
  Serial.println(roll); 
  
  Serial.println();
  Serial.println();

  delay(1000);
}

 

Two new functions are used here:

  • getPitch() returns the pitch angle (x-axis).
  • getRoll() returns the roll angle (y-axis).

Output of ICM20948_07_pitch_and_roll.ino

ICM-20948 - Output of ICM20948_07_pitch_and_roll.ino
Output of ICM20948_07_pitch_and_roll.ino

Outlook

The second part of the article is about interrupts, the low-power mode and the FIFO buffer.

4 thoughts on “ICM-20948 9-Axis Sensor Part I

  1. Many thanks Wolfgang, another excellent library. Word of warning the ADAFRUIT module is at 0x69 not 68 as in your examples. Also there appears to be a number of modules on Amazon that do not work properly, my guess is that they have failed chips, I have one that Y works perfectly, Z never works and X works sometimes! In the UK I got my fully working one from the Pi Hut

    1. Hi Howard, yes it seems the Adafruit module pulls the address pin up. The one you see on my images does not have any pull-up or pull-down. And yes, unfortunately there’s much scrap out there. I was lucky with my ICM-20948 modules (from AliExpress by the way), but I have seen a lot other fake or damaged modules. I didn’t try Pi Hut yet – looks good, I just had a look. Thanks for feedback & comments!

  2. Well Done… I’ve looked for a “down to basics” first approach to the ICM20948.. I think you are one of a very few that
    explains the workings… and makes one feel that we can utilize this module..

Leave a Reply

Your email address will not be published. Required fields are marked *