Table des matières

Convertisseur analogique-numérique 16 bits I2C

1. Introduction

Ce document montre comment utiliser le convertisseur analogique-numérique ADS1115 avec une carte Arduino. Il s'agit d'un convertisseur (ADC) 16 bits (de type sigma-delta) dont la fréquence d'échantillonnage peut aller jusqu'à 860Hz. Il comporte un amplificateur à gain programmable (PGA) et un multiplexeur permettant de faire des mesures sur 4 voies simples ou sur 2 voies en mode différentiel. L'interface numérique est I2C. Ce convertisseur est intéressant pour les mesures physiques nécessitant une grande précision de numérisation (16 bits) ou un fort gain d'amplification (jusqu'à 24).

Nous utilisons une platine fabriquée par adafruit :

platine ads1115

L'accès à l'ADS1115 depuis l'arduino se fait avec la bibliothèque de fonctions IC2devlib/ADS1115.

On verra comment utiliser l'achantilloneur intégré à l'ADS1115 et effectuer un filtrage numérique dans l'arduino. Cela permettra de mettre en œuvre la technique du filtre anti-repliement numérique. Lorsqu'on mesure un signal (délivré par un capteur) variant très lentement, avec une fréquence de l'ordre de fs, on a intérêt à échantillonner à une fréquence beaucoup plus grande que 2fs (sur-échantillonnage) de manière à bien échantillonner le bruit. Un filtrage numérique passe-bas anti-repliement est appliqué avant d'abaisser la fréquence d'échantillonnage afin de réduire le flux de données à transmettre et à traiter.

2. Montage électrique

L'ADS1115 est alimenté par la carte arduino : la borne GND est reliée à la borne GND de l'arduino et la borne VDD est reliée à la borne +5V de l'arduino si celui-ci fonctionne en +5 V (arduino nano, uno, mega). Si l'arduino fonctionne en 3,3 V (arduino yun, nano 33 ble, etc.) il faut relier VDD à la borne 3,3 V de l'arduino (et non pas à la borne +5 V). Les tensions appliquées sur les entrées analogiques (A0,A1,A2,A3) doivent être comprises entre 0 et VDD. Lorsque la tension à mesurer est délivrée par un capteur lui-même alimenté par GND et VDD, cela ne pose pas de problème. Si l'on veut mesurer une tension alternative, on place en amont de l'entrée un circuit à base d'amplificateur linéaire intégré, qui permet d'ajouter un décalage à une tension alternative. Ce circuit a en outre l'intérêt de protéger l'entrée de l'ADS1115 d'éventuelles tensions sortant de la place [GND,VDD].

ads1115.svgFigure pleine page

Les deux bornes de l'interface I2C (horloge SCL et donnée SDA) sont reliées aux bornes de même nom de l'arduino (bornes A5 et A4 sur les arduinos ne comportant pas de bornes réservées SCL et SDA, par ex. l'arduino nano). La sortie ALRT délivre une impulsion lorsqu'une donnée est disponible : on utilisera ce signal pour déclencher une interruption via l'entrée D2 de l'arduino. L'amplificateur TLV2372 (qui existe en version DIP) est choisi en raison de sa sortie pleine échelle (rail to rail). La sortie de cet amplificateur, envoyée sur l'entrée A0 du convertisseur, est donc comprise entre 0 et VDD. Lorsque la tension d'entrée U est nulle, cette tension est V0=2V+. On règle alors le potentiomètre pour qu'elle soit à peu près égale à VDD/2. Un ajustement très précis du zéro (correspondant à cette tension) se fera de manière logicielle.

3. Programme arduino

On utilise l'échantillonneur intégré à l'ADS1115. Celui-ci fait les conversions en continu à une fréquence d'échantillonnage choisie (8, 16, 32, 64, 128, 250, 475 ou 860 Hz). À chaque fois qu'une conversion est terminée, une impulsion est générée sur la sortie ALRT (la conversion est terminée simultanément avec le front descendant de cette impulsion). On utilise cette impulsion pour déclencher une interruption, au cours de laquelle on procède au filtrage des échantillons et à leur stockage dans un tampon. Lorsque le tampon est plein, les données sont transmises via le port série à un programme python (décrit plus loin).

arduinoADS1115.ino
#include "ADS1115.h"

#define SET_ACQUISITION 100
#define STOP_ACQUISITION 101
//#define DEBUG
#define N 200 // taille maximale du tampon
#define NF 21 // taille maximale du filtre convolutif
ADS1115 adc0(ADS1115_DEFAULT_ADDRESS);
const int alertReadyPin = 2;
    		   

Si la macro DEBUG est définie, le programme affiche les valeurs sur la console. Dans le cas contraire, les données sont transmises au programme python. La taille maximale du filtre convolutif (en principe impaire) pourra être augmentée si l'arduino utilisé possède assez de mémoire.

Voici les variables globales :

uint32_t taille_tampon;
uint16_t x0,x1;
uint16_t tampon[2][N];
int16_t i_tampon;
uint8_t n_tampon;
bool tampon_plein;
bool acquisition;
float fact_conv;
uint8_t mux;
uint8_t gain;
uint8_t rate;
bool conversion;
float b[NF];
uint16_t tampon_filtre[NF];
int16_t i_tamp_filtre;
int16_t n_filtre = 21;
bool filtrage;
int8_t reduc;
int8_t i_reduc;
    		   

tampon est un tableau contenant deux tampons qui seront remplis alternativement. Pendant qu'un des deux tampons est en cours de remplissage, l'autre peut être utilisé pour la transmission des données au programme python.

La fonction data_ready est appelée lors de l'interruption (déclenchée par le front descendant d'une impulsion sur l'entrée D2). Le dernier résultat de conversion se trouve dans la variable global x0, mise à jour dans la fonction loop décrite plus loin. Si le filtrage est activé, il est stocké dans un tampon circulaire (tampon_filtre) utilisé pour le filtrage par convolution, défini par les coefficients stockés dans le tableau b. Les échantillons (filtrés ou pas) sont ensuite stockés dans le tampon mais avec éventuellement une réduction de la fréquence d'échantillonnage. On pourra par exemple échantillonner à 128Hz, effectuer un filtrage passe-bas anti-repliement avec une fréquence de coupure de 5Hz et récupérer les échantillons à une fréquence de 12,8Hz, soit une réduction d'un facteur 10. La fréquence finale doit être supérieure au double de la fréquence de coupure du filtre passe-bas (condition de Nyquist-Shannon).

void data_ready() {
    if (acquisition) {
      if (filtrage) {
        int16_t j,k;
        float accum;
        tampon_filtre[i_tamp_filtre] = x0;
        j = i_tamp_filtre;
        accum = 0.0;
        for (k=0; k<n_filtre; k++) {
            accum += b[k]*tampon_filtre[j];
            j -= 1;
            if (j<0) j = n_filtre-1;
        }
        i_tamp_filtre += 1;
        if (i_tamp_filtre==n_filtre) i_tamp_filtre = 0;
        x0 = accum;
      }
    i_reduc += 1;
    if (i_reduc == reduc) {
        i_reduc = 0;
        tampon[n_tampon][i_tampon] = x0;
        i_tampon += 1;
        if (i_tampon==taille_tampon) {
            i_tampon = 0;
            tampon_plein = true;
            n_tampon += 1;
            if (n_tampon==2) n_tampon = 0;
          }
        }
    }
    conversion = true;
}   		       
    		       

La fonction calcul_filtre calcule les coefficients d'un filtre RIF de convolution à nf coefficients avec une fréquence de coupure relative à la fréquence d'échantillonnage égale à fc :

void calcul_filtre(int nf, float fc) {
  int P = (nf-1)/2;
  int k;
  float u;
  int i;
  for (i=0; i<nf; i++) {
      k = i-P;
      u = 2*PI*k*fc;
      if (u==0) b[i] = 2*fc;
      else b[i] = 2*fc*sin(2*PI*k*fc)/(2*PI*k*fc);
      b[i] *= 0.54+0.46*cos(2*PI*k/(2*P)); // Hamming
      //b[i] *= 0.5+0.5*cos(2*PI*k/(2*P)); // Hann
  }
}    		     
    		     

La fonction lecture_acquisition est exécutée lorsque le programme python demande de démarrer une acquisition. Elle lit les informations envoyées par ce programme (configuration du multiplexeur, gain de l'amplificateur, fréquence d'échantillonnage, taille du tampon, caractéristiques du filtre, facteur de réduction de l'échantillonnage) puis déclenche l'acquisition.

void lecture_acquisition() {
   uint32_t c1,c2;
   while (Serial.available()<5) {};
    mux = Serial.read();
    gain = Serial.read();
    rate = Serial.read();
    c1 = Serial.read();
    c2 = Serial.read();
    taille_tampon = (c1<<8) | c2;
    if (taille_tampon > N) taille_tampon = N;
   while (Serial.available()<3) {};
   n_filtre = Serial.read();
   if (n_filtre>NF) n_filtre = NF;
   c1 = Serial.read();
   float fc = 1.0/c1;
   reduc = Serial.read();
   i_reduc = 0;
   if (n_filtre!=0) {
      calcul_filtre(n_filtre,fc);
      filtrage = true;
   }
   else filtrage = false;
   adc0.setMode(ADS1115_MODE_CONTINUOUS);
   adc0.setConversionReadyPinMode();
   adc0.setMultiplexer(mux);
   adc0.setRate(rate);
   adc0.setGain(gain);
   i_tampon = 0;
   n_tampon = 0;
   tampon_plein = false;
   adc0.triggerConversion();
   acquisition = true;
   conversion = true;
}
void stop_acquisition() {
  acquisition = false;
}
    		      

La fonction setup configure la liaison série, l'ADS1115, et affecte la fonction data_ready à l'interruption déclenchée par un front descendant sur l'entrée D2. Si le mode DEBUG est activé, elle effectue une configuration du convertisseur et le démarre.

void setup() {
  Wire.begin();
    char c;
    Serial.begin(115200); 
    Serial.setTimeout(0);
    c = 0;
    Serial.write(c);
    c = 255;
    Serial.write(c);
    c = 0;
    Serial.write(c);
    acquisition = false;
    pinMode(alertReadyPin,INPUT_PULLUP);
    attachInterrupt(digitalPinToInterrupt(alertReadyPin),data_ready,FALLING);
    adc0.initialize(); 
    
#ifdef DEBUG
	i_tampon = 0;
    taille_tampon = 16;
    tampon_plein = false;
    i_tamp_filtre = 0;
    calcul_filtre(21,0.1);
    filtrage = true;
    reduc = 1;
    i_reduc = 0;
    adc0.setConversionReadyPinMode();
    adc0.setMode(ADS1115_MODE_CONTINUOUS);
    adc0.setRate(ADS1115_RATE_16); // 16 Hz
    adc0.setGain(ADS1115_PGA_6P144); // pas d'amplification (tension entre 0 et VDD)
    fact_conv = ADS1115_MV_6P144;
    adc0.setMultiplexer(ADS1115_MUX_P0_NG);
    acquisition = true;
    adc0.triggerConversion();
    conversion = true;
#endif
}	       
    		       

La fonction loop scrute le port série pour savoir si le programme python demande de démarrer une acquisition ou de la stopper. Si une acquisition est active, elle récupère le résultat de la dernière conversion (appel non bloquant adc0.getConversion) puis transmet l'un des deux tampons (celui qui n'est pas en cours d'utilisation par la fonction data_ready). Les données sont transmises sous forme d'entiers 16 bits (sans conversion en tension). Si le mode DEBUG est activé, elle affiche les valeurs de la tension sur la console, en volts. L'appel adc0.getConversion, qui ne fait que lire le registre de l'ADS1115 contenant le résultat de la dernière conversion, ne peut être placé dans la fonction data_ready, qui serait pourtant sa place logique mais ne convient pas car l'interface I2C ne peut être utilisée dans une fonction d'interruption (car elle fait elle-même appel à des interruptions). La variable booléenne conversion est mise à true à la fin de la fonction data_ready. De cette manière, la lecture du registre se fait à la fréquence d'échantillonnage et on évite donc les lectures inutiles.

void loop() {
   char com;
   uint8_t nt;
   if (Serial.available()>0) {
      com = Serial.read();
      if (com==SET_ACQUISITION) lecture_acquisition();
       else if (com==STOP_ACQUISITION) stop_acquisition();
   }
   
   if (acquisition) {
     if (conversion) {
        x0= adc0.getConversion(false);
        conversion = false;
     }
     if (tampon_plein) {
        if (n_tampon==0) nt=1; else nt=0;
        tampon_plein = false;
#ifdef DEBUG
        for (int i=0; i<taille_tampon; i++) {
            Serial.println(tampon[nt][i]*fact_conv);
        }     
#else      
        Serial.write((uint8_t *)tampon[nt],2*taille_tampon);
         
#endif
     }
   }
}   		       
    		       

4. Programme python

Le programme python est constitué d'une classe Arduino qui gère les communications avec l'arduino. Le constructeur initialise la liaison série et définit les constantes permettant la configuration du convertisseur.

arduinoADS1115.py
import numpy
import serial
import matplotlib.pyplot as plt

class Arduino():
    def __init__(self,port):
        self.ser = serial.Serial(port,baudrate=115200)
        c_recu = self.ser.read(1)
        while ord(c_recu)!=0:
            c_recu = self.ser.read(1)
        c_recu = self.ser.read(1)
        while ord(c_recu)!=255:
            c_recu = self.ser.read(1)
        c_recu = self.ser.read(1)
        while ord(c_recu)!=0:
            c_recu = self.ser.read(1)

        self.TAILLE_TAMPON_MAX = 255
        self.START_ACQUISITION = 100
        self.STOP_ACQUISITION = 101
        
        # multiplexeur
        self.MUX_P0_N1 = 0x00 # différentiel A0-A1
        self.MUX_P0_N3 = 0x01 # différentiel A0-A3
        self.MUX_P1_N3 = 0x02 # différentiel A1-A3
        self.MUX_P2_N3 = 0x03 # différentiel A2-A3
        self.MUX_P0_NG = 0x04 # simple A0
        self.MUX_P1_NG = 0x05 # simple A1
        self.MUX_P2_NG = 0x06 # simple A2
        self.MUX_P3_NG = 0x07 # simple A3
        
        # amplificateur
        self.PGA_6P144 = 0x00 # maximum 6.144 V (pas d'amplification)
        self.PGA_4P096 = 0x01 # maximum 4.096 V
        self.PGA_2P048 = 0x02 # maximum 2.048
        self.PGA_1P024 = 0x03 # maximum 1.024 V
        self.PGA_0P512 = 0x04 # maximum 0.512 V
        self.PGA_0P256 = 0x05 # maximum 0.256 V
        self.PGA_0P256B = 0x06 
        self.PGA_0P256C = 0x07
        
        # fréquence d'échantillonnage
        self.RATE_8 = 0x00 # 8 Hz
        self.RATE_16 = 0x01 # 16 Hz
        self.RATE_32 = 0x02 # 32 Hz
        self.RATE_64 = 0x03 # 64 Hz
        self.RATE_128 = 0x04 # 128 Hz
        self.RATE_250 = 0x05 # 250 Hz
        self.RATE_475 = 0x06 # 475 Hz
        self.RATE_860 = 0x07 # 860 Hz

        self.gain = self.PGA_6P144
        self.conversion = [0.1875,0.125,0.0625,0.03125,0.015625,0.007813,0.007813,0.007813]
        self.temps = [1/8,1/16,1/32,1/64,1/128,1/250,1/475,1/860]    		 
    		 

Le résultat de la conversion est un nombre entier 16 bits avec signe. En effet, l'utilisation du multiplexeur en mode différentiel peut donner des valeurs négatives. Le réglage du gain en mode PGA_6P144 permet en principe de convertir des tensions comprises entre -6,144V et 6,144V, en nombres entiers de 16 bits de -32768 à 32768. Si le multiplexeur est configuré en mode simple (par ex MUX_P0_NG), la tension convertie est en fait comprise entre 0 et VDD. Dans le cas où VDD=5 V, on obtient donc un nombre compris entre 0 et 26667, soit un nombre de bits effectifs de 14,7. Si le gain est réglé en mode PGA_1P024 et le multiplexeur en mode différentiel (par ex MUX_P0_N3), on peut mesurer une différence de potentiel comprise entre -1,024V et 1,024V, qui donne un nombre entier compris entre -32768 et 32768, soit un nombre de bits effectivement égal à 16. Attention : en mode différentiel, la différence de potentiel peut être négative mais le potentiel appliqué sur chacune des entrées (A0 et A3) par rapport à la masse doit être compris entre 0 et VDD. Pour appliquer une tension inférieure à la masse, il faut obligatoirement utiliser le circuit de décalage décrit plus haut.

Ci-dessous la fonction de fermeture de la liaison avec l'arduino et les fonctions permettant d'envoyer des entiers 8, 16 ou 32 bits.

    def close(self):
        self.ser.close()
    def write_int8(self,v):
    	char = int(v&0xFF) # nécessaire pour les nombres négatifs
    	self.ser.write((char).to_bytes(1,byteorder='big'))
    def write_int16(self,v):
        v = numpy.int16(v)
        char1 = int((v & 0xFF00) >> 8)
        char2 = int((v & 0x00FF))
        self.ser.write((char1).to_bytes(1,byteorder='big'))
        self.ser.write((char2).to_bytes(1,byteorder='big'))
    def write_int32(self,v):
        v = numpy.int32(v)
        char1 = int((v & 0xFF000000) >> 24)
        char2 = int((v & 0x00FF0000) >> 16)
        char3 = int((v & 0x0000FF00) >> 8)
        char4 = int((v & 0x000000FF))
        self.ser.write((char1).to_bytes(1,byteorder='big'))
        self.ser.write((char2).to_bytes(1,byteorder='big'))
        self.ser.write((char3).to_bytes(1,byteorder='big'))
        self.ser.write((char4).to_bytes(1,byteorder='big'))

    	   

La fonction start_acquisition configure et déclenche une acquisition. On doit préciser la configuration du multiplexeur, le gain de l'amplificateur, la fréquence d'échantillonnage et éventuellement la taille et la fréquence de coupure du filtre (une taille nulle implique aucun filtrage). L'argument reduc précise le facteur de réduction de la fréquence d'échantillonnage.

    def start_acquisition(self,mux,gain,rate,tampon,nfiltre=0,fc=0.1,reduc=1):
        self.write_int8(self.START_ACQUISITION)
        self.gain = gain
        self.write_int8(mux)
        self.write_int8(gain)
        self.write_int8(rate)
        self.write_int16(tampon)
        self.write_int8(nfiltre)
        self.write_int8(int(1/fc))
        self.write_int8(reduc)
        self.taille_tampon = tampon
        self.techant = self.temps[rate]*reduc

    def stop_acquisition(self):
        self.write_int16(self.STOP_ACQUISITION)
    	   

La fonction lecture récupère le contenu d'un tampon et renvoie un tableau de valeurs converties en volts. Dans le cas où on utilise le circuit de décalage décrit plus haut, il faudra retrancher la valeur de la tension obtenue lorsque U=0.

    def lecture(self):
        buf = self.ser.read(self.taille_tampon*2)
        x = numpy.zeros(self.taille_tampon,dtype=numpy.float32)
        for i in range(self.taille_tampon):
            v = buf[2*i]+buf[2*i+1]*0x100
            x[i] = v *self.conversion[self.gain]
        return x    	    
    	    

La fonction suivante, ajoutée à la suite de la définition de la classe, permet de faire un test :

def test():
    ard = Arduino("COM5")
    N = 40
    y = numpy.zeros(0)
    ard.start_acquisition(ard.MUX_P0_NG,ard.PGA_6P144,ard.RATE_250,N)
    for k in range(20):
        x = ard.lecture()
        print(x)
        y = numpy.append(y,x)
    ard.stop_acquisition()
    t = numpy.arange(len(y))*ard.techant
    plt.figure()
    plt.plot(t,y)
    plt.xlabel("t (s)")
    plt.ylabel("u (mV)")
    plt.grid()
    plt.show()    	    
    	    

Le script suivant permet de faire une acquisition avec un tracé en temps réel. À la fin de l'acquisition, le spectre du signal est tracé et il est enregistré dans un fichier texte. Un test complet consiste à numériser un signal délivré par un générateur de fonctions et à vérifier la fréquence sur le spectre.

arduinoADS1115-animate.py
import numpy
from matplotlib.pyplot import *
import matplotlib.animation as animation
from arduinoADS1115 import *
import numpy.fft

ard = Arduino("COM5")
duree = 30
duree_fenetre = 10
N_tampon = 40
Umin = 0
Umax = 5000
reduc=1
ard.start_acquisition(ard.MUX_P0_NG,ard.PGA_6P144,ard.RATE_250,N_tampon,21,0.1,reduc)
techant = ard.techant
N_fenetre = int(duree_fenetre/techant)
t = numpy.arange(N_fenetre)*techant
x = numpy.zeros(N_fenetre)

fig,ax = subplots()
line0, = ax.plot(t,x)
ax.set_xlabel("t (s)")
ax.set_ylabel("U (mV)")
ax.grid()

n=0
y = numpy.zeros(0)
temps = numpy.zeros(0)
N = int(duree/(techant*N_tampon))
t = 0.0
n_echant = 0

def animate(i):
    global ax,line0,Umin,Umax,ard,n,N_fenetre,N_tampon,techant,n_echant,t,y,temps
    if n<N:
        n += 1
        x = ard.lecture()
        y = numpy.append(y,x)
        temps = numpy.append(temps,numpy.arange(N_tampon)*techant+t)
        n_echant += N_tampon
        t += N_tampon*techant
        if n_echant >= N_fenetre:
            line0.set_ydata(y[n_echant-N_fenetre:n_echant])
            line0.set_xdata(temps[n_echant-N_fenetre:n_echant])
            ax.axis([temps[n_echant-N_fenetre],temps[n_echant-1],Umin,Umax])
        else:
            line0.set_ydata(y[0:n_echant])
            line0.set_xdata(temps[0:n_echant])
            ax.axis([0,N_fenetre*techant,Umin,Umax])
    else:
        ard.stop_acquisition()

ani = animation.FuncAnimation(fig,animate,N,interval=techant*N_tampon*1000*0.8)
show()
ard.stop_acquisition()
ard.close()
Ne = len(y)
spectre = numpy.absolute(numpy.fft.fft(y))*2/N
freq = numpy.arange(Ne)*1/(temps[Ne-1]-temps[0])
figure()
plot(freq,spectre)
xlim(0,10)
grid()
xlabel("f (Hz)")
ylabel("A (mV)")
show()
numpy.savetxt("signal.txt",numpy.array([temps,y]).T,delimiter="\t",fmt="%.6e",header="t (s)\t U (mV)")
    	     
Creative Commons LicenseTextes et figures sont mis à disposition sous contrat Creative Commons.