Aplicação para se obter dados coerentes de uma IMU (parte 1)
1 - IMU (Unidade Inercial de Medidas)
Nesse primeiro teste, iremos utilizar o sensor MPU6550 que contém um acelerômetro e um giroscópio, fornecendo 6 DOF (graus de liberdade), além de um sensor de temperatura. (Detalhes do funcionamento em: https://www.embarcados.com.br/sensores-inerciais-parte-2/ )
O acelerômetro fornece os dados de aceleração nos 3 eixos em [latex] [m/s^2] [/latex] ([latex] a_x [/latex], [latex] a_y [/latex] e [latex] a_z [/latex]).
O giroscópio fornece a variação dos ângulos nos 3 eixos em [latex][°/s][/latex] ([latex] \dot{\phi} [/latex], [latex] \dot{\theta} [/latex] e [latex] \dot{\psi}[/latex]).
Vamos tratar aqui de como obter os ângulos pitch [latex] (\theta) [/latex] e roll [latex] (\phi) [/latex], mostrados na Figura 1.
Figura 1 - Eixos de referência.
2 - Montagem
A conexão entre o MPU6050 e o ESP32 (Dev Kit) é feita por meio da comunicação I2C. Os pinos 22 e 23 foram utilizados, respectivamente, como pino SDA (dados) e pino SCL (clock). A alimentação do MPU6050 utilizada foi de 3,3V (fornecida pelo próprio ESP32) e o GND foi interligado. Mais detalhes podem ser vistos na Figura 2.
Figura 2 - Montagem ESP32 + MPU6050.
3 - Código embarcado
Como a ideia é testar o filtro de Kalman, optou-se por implementá-lo em python. Dessa forma, o ESP32 é responsável apenas por fazer a leitura da IMU e enviar os dados para o computador (conexão usb-serial).
Compile e faz o envio do seguinte código para o ESP32:
#include <Wire.h>
const int MPU_addr =0x 68 ; // I2C address of the MPU-6050
int16_t AcX,AcY,AcZ,Tmp,GyX,GyY,GyZ;
float AccX, AccY, AccZ;
float GyroX, GyroY, GyroZ;
float elapsedTime = 0.0 , currentTime = 0.0 , previousTime = 0.0 ;
void setup (){
Wire. begin ( 22 , 23 );
Wire. beginTransmission (MPU_addr);
Wire. write ( 0x 6B ); // PWR_MGMT_1 register
Wire. write ( 0x 00 ); // set to zero (wakes up the MPU-6050)
Wire. endTransmission ( true );
Wire. beginTransmission (MPU_addr); // inicia comunicação com endereço do MPU6050
Wire. write ( 0x 1B ); // envia o registro com o qual se deseja trabalhar
Wire. write ( 0x 00 ); // escreve o valor no registro
Wire. endTransmission ( true ); // termina a transmissão
Serial. begin ( 115200 );
}
void loop (){
// === Read acceleromter data === //
Wire. beginTransmission (MPU_addr);
Wire. write ( 0x 3B ); // Start with register 0x3B (ACCEL_XOUT_H)
Wire. endTransmission ( false );
Wire. requestFrom (MPU_addr, 6 , true ); // Read 6 registers total, each axis value is stored in 2 registers
//For a range of +-2g, we need to divide the raw values by 16384, according to the datasheet
// and for correct conversion you must use int16_t
AccX = int16_t (Wire. read () << 8 | Wire. read ()) / 16384.0 ; // X-axis value
AccY = int16_t (Wire. read () << 8 | Wire. read ()) / 16384.0 ; // Y-axis value
AccZ = int16_t (Wire. read () << 8 | Wire. read ()) / 16384.0 ; // Z-axis value
// === Read gyro data === //
Wire. beginTransmission (MPU_addr);
Wire. write ( 0x 43 ); // Gyro data first register address 0x43
Wire. endTransmission ( false );
Wire. requestFrom (MPU_addr, 6 , true ); // Read 4 registers total, each axis value is stored in 2 registers
GyroX = int16_t (Wire. read () << 8 | Wire. read ()) / 131.0 ; // For a 250deg/s range we have to divide first the raw value by 131.0, according to the datasheet
GyroY = int16_t (Wire. read () << 8 | Wire. read ()) / 131.0 ;
GyroZ = int16_t (Wire. read () << 8 | Wire. read ()) / 131.0 ;
// === Send data (Serial) === //
Serial. print ( "$" );
Serial. print (AccX);
Serial. print ( "$" );
Serial. print (AccY);
Serial. print ( "$" );
Serial. print (AccZ);
Serial. print ( "$" );
Serial. print (GyroX);
Serial. print ( "$" );
Serial. print (GyroY);
Serial. print ( "$" );
Serial. print (GyroZ);
Serial. print ( "$#" );
Serial. println ();
currentTime = millis (); // Current time actual time read
elapsedTime = (currentTime - previousTime) ; // Divide by 1000 to get seconds
previousTime = currentTime; // Previous time is stored before the actual time read
int t = ( 20 - elapsedTime) > 0 ? int ( 20 - elapsedTime) : 0 ;
delay (t); //for 50Hz
// Serial.print(elapsedTime);
// Serial.println();
}
Verifique se os dados estão sendo enviados corretamente:
Figura 3 - Dados (raw) do acelerômetro e do giroscópio.
4 - Leitura dos dados (python)
Para realizar a leitura dos dados enviados pelo ESP32 via usb-serial, é necessário instalar a biblioteca pyserial.
Obs.: Se estiver utilizando o Anaconda, vá ao seu prompt de comando e digite:
pip install pyserial
Crie um arquivo .py para a leitura correta dos dados, obtendo os valores enviados pelo ESP32 em float. Segue um código exemplo:
### Leitura dos dados da MPU6050
import serial
import time
import numpy as np
import matplotlib.pyplot as plt
import msvcrt
import sys
def getData (serialPort, char_init = "$" , char_end = "#" ):
""" Obtém os dados da serial
dado esperado: $ax$ay$az$wx$wy$wz$#
"""
line = serialPort.readline().decode( encoding = 'utf-8' , errors = 'ignore' ) #converte bytes para string
line = line.strip( " \r\n " ) #remove tabulação final
try :
if line[ 0 ] == char_init:
if line[ - 1 ] == char_end:
dados = line.split(char_init)[ 1 :]
dados = dados[: - 1 ] # retira o último elemento #
if len (dados) == 6 :
dados = [ float (v) for i,v in zip ( range ( 0 , 6 ),dados)] #converte para float
return True , dados
return False , [ - 1 , - 1 , - 1 , - 1 , - 1 , - 1 ]
except :
return False , [ - 1 , - 1 , - 1 , - 1 , - 1 , - 1 ]
def saveData (data, Acx, Acy, Acz, Gx, Gy, Gz):
""" adiciona os dados recebidos nas devidas listas """
Acx.append(data[ 0 ])
Acy.append(data[ 1 ])
Acz.append(data[ 2 ])
Gx.append(data[ 3 ])
Gy.append(data[ 4 ])
Gz.append(data[ 5 ])
def drawData (Acx, Acy, Acz, Gx, Gy, Gz):
""" Plota os dados obtidos do acelerômetro e giroscópio na forma de histograma e ao longo do tempo """
N = len (Acx)
mean_Acx = np.round(np.mean(Acx), 7 )
cov_Acx = np.round(np.cov(Acx), 7 )
mean_Acy = np.round(np.mean(Acy), 7 )
cov_Acy = np.round(np.cov(Acy), 7 )
mean_Acz = np.round(np.mean(Acz), 7 )
cov_Acz = np.round(np.cov(Acz), 7 )
mean_Gx = np.round(np.mean(Gx), 7 )
cov_Gx = np.round(np.cov(Gx), 7 )
mean_Gy = np.round(np.mean(Gy), 7 )
cov_Gy = np.round(np.cov(Gy), 7 )
mean_Gz = np.round(np.mean(Gz), 7 )
cov_Gz = np.round(np.cov(Gz), 7 )
fig = plt.figure( figsize = ( 5 , 5 ))
ax = fig.add_subplot( 231 )
ax.hist(Acx, bins = 100 , color = '#e24a33' , density = True )
ax.set_title( "Acx - média = " + str (mean_Acx))
ax = fig.add_subplot( 234 )
ax.plot( range (N), Acx, '.' )
ax.set_title( "Acx - variância = " + str (cov_Acx))
ax = fig.add_subplot( 232 )
ax.hist(Acy, bins = 100 , color = '#e24a33' , density = True )
ax.set_title( "Acy - média = " + str (mean_Acy))
ax = fig.add_subplot( 235 )
ax.plot( range (N), Acy, '.' )
ax.set_title( "Acy - variância = " + str (cov_Acy))
ax = fig.add_subplot( 233 )
ax.hist(Acz, bins = 100 , color = '#e24a33' , density = True )
ax.set_title( "Acz - média = " + str (mean_Acz) )
ax = fig.add_subplot( 236 )
ax.plot( range (N), Acz, '.' )
ax.set_title( "Acz - variância = " + str (cov_Acz))
fig = plt.figure( figsize = ( 5 , 5 ))
ax = fig.add_subplot( 231 )
ax.hist(Gx, bins = 100 , color = '#e24a33' , density = True )
ax.set_title( "Gx - média = " + str (mean_Gx))
x = np.linspace( min (Gx) - 1 , max (Gx) + 1 ,N // 2 )
f = 1 / ((cov_Gx ** 0.5 ) * np.sqrt( 2 * np.pi)) * np.exp( - 0.5 * ((x - mean_Gx) / (cov_Gx ** 0.5 )) ** 2 )
ax.plot(x,f, 'b' )
ax = fig.add_subplot( 234 )
ax.plot( range (N), Gx, '.' )
ax.set_title( "Gx - variância = " + str (cov_Gx))
ax = fig.add_subplot( 232 )
ax.hist(Gy, bins = 100 , color = '#e24a33' , density = True )
ax.set_title( "Gy - média = " + str (mean_Gy))
x = np.linspace( min (Gy) - 1 , max (Gy) + 1 ,N // 2 )
f = 1 / ((cov_Gy ** 0.5 ) * np.sqrt( 2 * np.pi)) * np.exp( - 0.5 * ((x - mean_Gy) / (cov_Gy ** 0.5 )) ** 2 )
ax.plot(x,f, 'b' )
ax = fig.add_subplot( 235 )
ax.plot( range (N), Gy, '.' )
ax.set_title( "Gy - variância = " + str (cov_Gy))
ax = fig.add_subplot( 233 )
ax.hist(Gz, bins = 100 , color = '#e24a33' , density = True )
ax.set_title( "Gz - média = " + str (mean_Gz) )
x = np.linspace( min (Gy) - 1 , max (Gz) + 1 ,N // 2 )
f = 1 / ((cov_Gz ** 0.5 ) * np.sqrt( 2 * np.pi)) * np.exp( - 0.5 * ((x - mean_Gz) / (cov_Gz ** 0.5 )) ** 2 )
ax.plot(x,f, 'b' )
ax = fig.add_subplot( 236 )
ax.plot( range (N), Gz, '.' )
ax.set_title( "Gz - variância = " + str (cov_Gz))
plt.show()
if __name__ == "__main__" :
Ts = 1 / 50.0 #tempo de amostragem
serialPort = serial.Serial( port = "COM5" , baudrate = 115200 , bytesize = 8 , timeout = 2 , stopbits = serial. STOPBITS_ONE )
serialPort.timeout = 2 # set read timeout
serialPort.flush()
print ( "Conexão {} " .format(serialPort.is_open)) # True for opened
if not serialPort.is_open:
print ( 'serialPort not open' )
sys.exit()
Acx, Acy, Acz, Gx, Gy, Gz = [], [], [], [], [], []
t1, t2, t_total = 0.0 , 0.0 , 0.0
t1 = time.time()
while True :
t2 = time.time()
dt = t2 - t1
if dt >= Ts:
ok, data = getData(serialPort)
t_total += dt
t1 = t2
if (ok):
saveData(data, Acx, Acy, Acz, Gx, Gy, Gz)
print ( "---------------------------------" )
print ( "tempo total = {} s" .format(t_total))
print (Acx[ - 1 ], Acy[ - 1 ], Acz[ - 1 ], Gx[ - 1 ], Gy[ - 1 ], Gz[ - 1 ])
print ( "número de amostras = " , len (Acx))
print ( "---------------------------------" )
if msvcrt.kbhit(): # para de capturar os dados após apertar ESC
if ord (msvcrt.getch()) == 27 :
break
drawData(Acx, Acy, Acz, Gx, Gy, Gz)
Ao rodar o programa, deixando a montagem em uma condição estacionária (considerada como condição inicial), poderá ser obtido dados similares a esse:
Figura 4 - Resultado no terminal.
Após aproximadamente 3000 amostras, é possível ver a característica normal dos dados na condição estacionária, obtendo um valor de média e de variância. Esses dados serão utilizados para calibrar o sensor e como parâmetro no filtro de Kalman.
Figura 5 - Dados do giroscópio.
Figura 6 - Dados do acelerômetro.
Obs.: Os dados do acelerômetro possuem uma variância menor do que a do giroscópio.
5 - Obtenção dos ângulos pitch e roll pelo acelerômetro
Podemos obter os ângulos roll e pitch por geometria:
Podemos definir uma função para retornar esses ângulos:
def getAnglesAcc (data):
""" Retorna o valor de ângulo (pitch e roll) obtido pelo acelerometro """
pitch = np.arctan2( - data[ 0 ] , np.sqrt(data[ 1 ] ** 2 + data[ 2 ] ** 2 ))
roll = np.arctan2(data[ 1 ] , data[ 2 ])
return pitch * 180 / np.pi, roll * 180 / np.pi
Podemos obter os seguintes valores de ângulos para um movimento do sensor:
Variação de aprox. 90° em torno de y e depois retorno a posição inicial (x3):
Figura 7 - Dados obtidos do ângulo pitch por meio do acelerômetro.
Variação de aprox. 90° em torno de x e depois retorno a posição inicial (x3):
Figura 8 - Dados obtidos do ângulo roll por meio do acelerômetro.
6 - Obtenção por Integração numérica dos dados do giroscópio
7 - Fusão sensorial
Outros
Calibração do Magnetômetro
https://github.com/kriswiner/MPU6050/wiki/Simple-and-Effective-Magnetometer-Calibration
https://diydrones.com/profiles/blogs/advanced-hard-and-soft-iron-magnetometer-calibration-for-dummies