Oxymètre de pouls

Juin 2023

J'ai voulu réaliser mon propre oxymètre de pouls à partir de composants du commerce, histoire de bien comprendre comment ça marche. Voilà ce que j'ai fait en 2018/2019.

Matériel

Dans la page précédente, j'avais fait la liste des courses pour réaliser un oxymètre de pouls. Pour me simplifier la tâche, j'ai réutilisé le même microcontrôleur et afficheur que pour mon compteur Geiger.

Et j'ai décidé d'utiliser le MAX30101 qui va faire tout le travail analogique, il suffira de le connecter au microcontrôleur. Il me restera surtout un travail de programmation, en particulier les algorithmes pour parvenir au SpO2.

Microcontrôleur

J'ai utilisé un MSP430FR2533 de Texas Instrument. Non seulement il n'est pas cher, mais il possède les caractéristiques suivantes :

  • 16k de FRAM, de la mémoire non volatile
  • assez de RAM pour contenir l'image complète de l'afficheur
  • une consommation quasi-nulle en veille, on parle de nanoAmpère
  • assez d'entrées-sorties pour piloter l'ensemble
  • pas regardant côté alimentation, une pile lithium suffit largement
MSP430FR2533

De plus, TI fournit gratuitement le logiciel de programmation et de debug, ça se pilote à travers 2 fils, plus la masse et l'alimentation.

Afficheur

Le Sharp LS013B4DN04 est un afficheur avec une technologie assez extraordinaire. Sharp memory LCD

  • LCD réflectif noir et blanc : fonctionne en réfléchissant la lumière, comme si on avait un petit miroir ou non pour chaque pixel. Pas besoin de rétro-éclairage.
  • Ecran 1.3 pouce (33 mm), cette fois j'utilise le 128x128 pixels
  • interface série SPI (donc peu de fils)
  • mais surtout, une dizaine de µW de consommation !

Pour faire simple côté connectique, autant se servir du BoosterPack de TI : Sharp® LCD BoosterPack (BOOSTXL-SHARP128) for the LaunchPad d'autant plus qu'il faut générer du 5 volts, et que cela est fait par la carte. Et en plus, c'est fait pour les microcontrôleurs TI, donc cela facilitera la programmation.

L'opération d'écriture est la plus longue, on parle de dizaines de millisecondes, pour une image, mais c'est acceptable, 10 à 15 images par seconde donneront un affichage réactif.

Accessoirement, j'ai ajouté un micro haut-parleur afin de produire le "bip-bip" classique pour rythmer les battements cardiaques. C'est juste une sortie du MSP430 qui le pilote directement, aucune ruse.

MAX30101

Le cœur du réacteur est le MAX30101.

Le MAX30101, avec ses capacités de découplage sur un support au pas 2.56 (DIL).

Comme j'ai pris le circuit seul, il a fallu que je le soude moi-même sur un support de mon cru. Ce n'est pas une bonne idée si vous avez des difficultés pour souder, surtout que le circuit n'est pas fait pour ça, et je vous conseille plutôt d'acheter une carte toute faite.

La datasheet du MAX30101 va devenir votre livre de chevet, il faut l'avoir :

Pour la programmation, il faudra se référer à la description des registres et éplucher tout ça.

Circuiterie

Le MAX30101 propose une interface I2C, soit 3 fils à connecter. Sauf que les tensions ne sont pas compatibles, il faut ruser un peu.

Tensions d'alimentation

En effet, le MSP430 est alimenté en 3.3 volts en nominal, mais cette tension peut baisser avec l'usure des piles, cette valeur assez importante (on pourrait utiliser le MSP430 à une tension plus faible) permet de l'utiliser à sa vitesse maximale, utile pour les calculs et le pilotage de l'écran qui réclame lui-même au moins 2.8 volts pour que son booster 5 volts fonctionne.

De plus, les LED requièrent au moins 3.3 volts pour fonctionner, ce qui est trop juste pour utiliser 2 piles AAA de 1.5 volt. Et il ne faut pas mégoter sur les tensions car l'intensité lumineuse des LED est un point-clé pour que l'oxymètre de pouls fonctionne correctement. Du coup, j'ai repiqué le 5 volts généré par un booster DCDC sur la carte de l'afficheur pour alimenter mes LEDs. Et ça aide.

alimentations

J'ai régulièrement lu la valeur des diverses tensions avec le microcontrôleur, de manière à détecter les baisses de tension et indiquer à l'utilisateur de changer les piles, sinon cela perturbe trop le fonctionnement, surtout dans le cas où j'utilise une pile lithium. Vaut mieux deux piles AAA pour cette application, afin d'avoir un courant suffisant pour les LEDs.

I2C level shifter

On se retrouve donc avec une alimentation d'environ 3 volts pour le microcontrôleur qui est peu regardant, et du 1.8 volt pour le MAX30101, qui doivent s'interfacer en I2C, donc pas à la même tension. Ce qui est un problème pour les signaux SCL et SDA (clock et data), mais pas pour INT (interruptions) qui est un simple tirage à la masse, une résistance de protection suffira.

La ruse consiste à utiliser ce qu'on appelle un level shifter, qui est en fait un simple transistor MOS qui adaptera la tension suivant le sens de transmission.

I2C level shifter
2 transistors et le tour est joué côté tension.
Les résistances seront celles disponibles en interne du MSP430 côté 3 volts.

A présent, tous les composants utiles sont connectés, reste à programmer tout ça.

Théorie

Commençons par examiner la théorie pour déterminer les calculs à faire.

Pour commencer, on aimerait en fait mesurer ce qui est appelé SaO2, le pourcentage d'hémoglobine transportant de l'oxygène "utile" par rapport à l'hémoglobine totale, y compris les formes malades ou contaminées (monoxyde de carbone), ceci dans le sang artériel (en sortie de poumon, si vous voulez) :

SaO2 (100 %)= [HbO2] / [Hb totale]

Si jamais les poumons n'arrivent pas à charger suffisamment l'hémoglobine, le SaO2 diminue et c'est l'hypoxie. Par exemple des poumons abîmés, du manque d'oxygène dans l'air, une respiration déficiente...

La mesure directe est assez délicate à réaliser, lente, avec du matériel peu commode (il faut un échantillon de sang artériel), mais c'est faisable.

Par dépit, on va mesurer le SpO2, le pourcentage d'hémoglobine chargée en oxygène par rapport à l'hémoglobine fonctionnelle (il manque les formes malades)

SpO2 (100 %) = [HbO2] / [Hb fonctionnelle]    

Ce qui peut se faire en utilisant la loi de Beer-Lambert concernant la lumière absorbée en fonction de la concentration du soluté, ici les différentes formes d'hémoglobine.

I = I0 e- ε(λ) C d

Avec :

  • I, I0 : intensité de la lumière finale/initiale
  • d : longueur du chemin optique
  • C : concentration (molaire) du soluté
  • ε(λ) : coefficient d'atténuation du soluté, dépend de la longueur d'onde

Et nous avions vu que le coefficient d'absorption varie avec la longueur d'onde :

courbe d'absorption hemoglobine
Absorption de l'hémoglobine oxygénée ou non en fonction de la longueur d'onde.

Sauf qu'une bonne partie de la lumière est absorbée par les tissus, la peau... mais on va pouvoir s'en accommoder.

Comme le cœur bat, il pousse le sang, et durant la systole, le capteur va voir plus de sang artériel, la longueur du chemin optique augmente, et le signal lumineux diminue. Puis à la diastole, quand le cœur ne pousse plus, on aura moins de sang artériel, et le signal lumineux augmentera. C'est grâce à la pulsation que l'on va pouvoir extraire les valeurs, en utilisant au moins deux longueurs d'onde.

signal pulsatile
Le signal lu, pour une longueur d'onde

On suppose que la partie dite AC (= variable) est due essentiellement au sang artériel chargé en oxygène, et que le reste est constant (sauf que l'on bouge, qu'il y a la lumière externe, et cela influe sur le signal lumineux comme on le verra).

Pour une longueur d'onde, nous avons donc les contributions des deux hémoglobines, oxygénée ou non. Il faut aussi ajouter la contribution des tissus, et tenir compte de la longueur du chemin optique qui augmente lors de la systole. . Nous avons donc (en prenant le logarithme pour éviter de se trimbaler des exponentielles) :

ln(Idiastole/I0) = -DC - ε(Hb,λ) C(Hb) d         - ε(HbO,λ) C(HbO) d
ln(Isystole/I0)  = -DC - ε(Hb,λ) C(Hb) (d+Δd) - ε(HbO,λ) C(HbO) (d+Δd)

Avec :

  • DC : contribution de la partie fixe, tissus et autres
  • d : longueur du chemin optique pour le sang, + Δd à la systole
  • On suppose que les concentrations ne varient pas

Du coup, on peut faire le rapport systole/diastole ce qui élimine la contribution des tissus et la longueur du chemin optique d :

ln(Isystole/Idiastole) = - ε(Hb,λ) C(Hb) Δd - ε(HbO,λ) C(HbO) Δd

Il reste l'augmentation du chemin optique Δd à éliminer, ce que l'on va pouvoir faire en utilisant deux longueurs d'onde. On refait donc un rapport, ce qui nous fait un rapport de rapport R, en éliminant Δd :

      ln(Isystole/Idiastole)660
R = ─────────────
      ln(Isystole/Idiastole)940
       ε(Hb,660) C(Hb) + ε(HbO,660) C(HbO)
R = ──────────────────────
       ε(Hb,940) C(Hb) + ε(HbO,940) C(HbO)

On peut alors calculer le SpO2 en fonction de R :

SpO2 = C(HbO) / [ C(Hb)+C(HbO) ]

                                  ε(Hb,660) - ε(HbO,660) R
SpO2 =  ────────────────────────────
              ε(Hb,660) - ε(HbO,660) + (ε(HbO,660) + ε(Hb,940)) R

D'autre part, le rapport R est calculable sans faire de logarithme en remarquant que AC≪DC, et le logarithme de 1+x, quand x est petit, vaut x :

ln(Isystole/Idiastole) = ln(AC+DC/DC) ≃ AC/DC

Il suffit donc de faire le simple rapport de rapport des deux longueurs d'onde pour obtenir R.

Certes, et les valeurs des coefficients d'atténuation ε ?

Eh bien c'est là qu'on vous enfume côté théorie. En effet, ce calcul est trop simple, et nous avons d'autres paramètres à prendre en compte comme le fait que les LED ont une certaine largeur de bande, que la sensibilité des photodiodes n'est pas linéaire et constante en fonction de la longueur d'onde, et d'autres paramètres secondaires mais néanmoins importants.

Vous trouverez plus de détails concernant les LED, la longueur d'onde, l'absorption, la sensibilité des photodiodes... dans la note d'application AN147 d'Osram :

Alors ça se termine toujours par une calibration, où il faudra utiliser une formule, souvent une table de correspondance (look-up table) pour aller du ratio R à la valeur SpO2.

Autrement dit, si vous faites vous-même votre oxymètre de pouls, et bien vous ne serez jamais sûr de son bon fonctionnement, car il faudra le calibrer. 😩

Programmation

Mon but n'est pas de détailler toute la programmation de la chose, ce serait très long et d'un intérêt relatif. Par contre, il existe des points très importants pour faire fonctionner cet oxymètre de pouls, parce que pour l'instant nous avons une puce capable de débiter des données, en veux-tu, en voilà, et un microcontrôleur qui va devoir traiter ces données pour en extraire le pouls et le SpO2, et afficher tout ça à la volée. Ce n'est pas gagné d'avance, il a fallu tâtonner, et je vous indiquerai uniquement le résultat qui fonctionne.

Acquisition de données

Le MAX30101 possède une FIFO (une file «premier entré, premier sorti») afin d'acquérir les données de manière autonome, et pousser les données vers le microcontrôleur lorsque celui-ci est disponible.

J'ai reprogrammé les échanges I2C spécifiquement pour vider la FIFO efficacement, et traiter les données dans la foulée. Il faut dire que j'ai une collection d'autres capteurs sur l'I2C, ainsi qu'une flash pour avoir de la place mémoire pour stocker les écrans d'affichage, car la mémoire du microcontrôleur est trop petite pour tout ce que j'ai fait sur cette carte.

Pour information, voici mes choix concernant la programmation du MAX30101, autrement dit comment j'ai initialisé les registres :

w(ModeConfiguration,0b01000000);//reset du MAX
r(InterruptStatus1,&MAX_IntStatus1,&MAX_IntStatus2);//INT à 1
w(InterruptEnable1,0b00010001);//autorise le proxy
w(InterruptEnable2,0b00000010);//ainsi que température ready
w(FIFO_ReadPointer,0x00);//pointeurs à zéro
w(OverflowCounter,0x00);
w(FIFO_ReadPointer,0x00);
w(FIFO_Configuration,0b00001111);//average 0, rollover false, fifo almost full 17
w(SpO2Configuration,0b00100111); //4096nA, 100Hz, pulse 411us
w(LED1_PA,0x30);// red 7mA 0x24
w(LED2_PA,0x33);// IR
w(LED3_PA,0x00);// verte éteinte
w(ProxModeLED_PA,0x7F);// 25mA pour la détection de proximité
w(ProxIntThreshold,0xF0);// seuil de détection
// maintenant, on doit attendre l'interruption de détection
w(ModeConfiguration,0b00000011);//SpO2: red and IR leds

En gros, le MAX30101 va provoquer une interruption quand il détectera un doigt, puis une interruption à chaque fois que sa FIFO commence à se remplir. Le MSP430 collectera les données et fera le traitement dans la foulée.

Accessoirement, j'affiche la température du capteur régulièrement.

Classiquement, lorsque les interruptions sont traitées, le MSP430 retourne en sommeil. Mais bon, une interruption claque toutes les secondes, au moins pour afficher le temps écoulé et montrer que le système n'est pas planté.

Redressement des données

Les données brutes se présentent mal, les variations du niveau central sont très importantes et rendent la détection des pics délicate :

On peut dire que c'est catastrophique à traiter pour obtenir un résultat fiable. Comment rendre ça "tout droit" ? Comme dans les schémas de principe ? Il s'agit d'éliminer la composante continue DC.

MAX30101 raw data

J'ai essayé pas mal de filtres proposés ici et là dans diverses implémentations que l'on peut trouver sur le web, ainsi que des reference designs où du code est fourni. J'ai même tenté une transformée de Fourier...

Et un miracle s'est produit. Un traitement tout con marche avec une efficacité redoutable. Il s'agit du DC removal.

w(t) = x(t) + α*w(t−1)
y(t) = w(t) − w(t−1)

avec :

  • x(t) entrée du filtre
  • y(t) sortie du filtre
  • w(t) valeur intermédiaire, agissant comme la mémoire
  • α réponse du filtre. 1 tout passe, 0 rien passe.

Il faut choisir correctement le paramètre du filtre α, et de préférence que ce soit facilement et rapidement calculable (pas de multiplication de nombres réels, je n'ai qu'un 16 bits...).

Après quelques essais, j'ai choisi 0.75, favorable pour les calculs :

// filtrage DC
// alpha: 0.85 NOK : 0.80 ça commence a marcher :
// 0.75 pas mal, 0.7 aussi, 0.5 ça filtre trop
long histo_new = *pFIFO++;
histo_new += ((LED->histo)>>1) + ((LED->histo)>>2);
// alpha: 0.75 = 1/2 + 1/4

long filtered = histo_new - LED->histo;
if (filtered> 15000) filtered= 15000; // on met un maximum
if (filtered<-15000) filtered=-15000;
*p_led1-- = -filtered; // on inverse car c'est l'habitude d'afficher le sang artériel en positif
LED->histo=histo_new;
// arrivé là, on a des échantillons centrés sur 0 et ne dépassant pas 15000

Remarquez que j'ai choisi des valeurs évitant des multiplications ou divisions trop coûteuses pour un microcontrôleur, en utilisant astucieusement les décalages. Et l'inversion du signal pour retrouver ce qu'on a l'habitude de voir.

Ce traitement est clé et central pour que la suite se passe bien. Et il est redoutable de simplicité et de rapidité. C'est probablement ce point le plus essentiel de toute l'implémentation.

Trouver la valeur de AC est triviale, il suffit de chercher le min-max dans la série d'échantillons du buffer de données.

Trouver les pics

Identifier les pics à coup sûr n'est pas si évident, on peut facilement se faire avoir avec un doublon local, mais d'un autre côté, une fois que vous avez le premier pic, le suivant ne peut pas être trop proche temporellement, par exemple vous pouvez décider que vous n'atteindrez jamais plus de 180 bpm, ni trop loin, par exemple en dessous de 40 bpm est très suspect.

J'ai vu pas mal d'idées plus ou moins efficaces dans les codes proposés sur ce sujet (j'ai même essayé une transformée de Fourier), et j'ai fini par programmer mon propre algorithme, rien ne me plaisait vraiment :

  • L'acquisition est à 100 Hz, et mon buffer de données fait 256 échantillons, soit 2.56 secondes, et on reçoit des paquets de 16 données, soit 2x8, 80 ms. Donc traitement toutes les 80 ms, nettement moins qu'un battement de cœur
  • Si l'écart min-max sur un paquet de données reçues dépasse 15000, c'est suspect et je jette le paquet. Sinon je filtre le DC (voir précédemment)
  • Pour trouver le pic suivant, je pars du dernier échantillon reçu :
    • il faut au moins une durée minimale correspondant à 180 bpm (choix arbitraire, je me connais)
    • je cherche la traversée de la moitié de la dernière valeur maximale en croissance (c'est là qu'est l'astuce)
    • à partir de ce moment-là, je recherche le passage par zéro. Si je le trouve, c'est un vrai pic.
  • Je stocke dans un petit tableau la position des quelques derniers pics, histoire de calculer le rythme cardiaque sur les 4 derniers pics pour lisser les variations.

C'est un algorithme vraiment efficace pour trouver facilement les pics sur ce genre de données.

Calcul du SpO2

Nous avons le ratio de ratio à calculer à partir de :

  • du max-min des données, ce qui donnera AC
  • d'une valeur moyenne des données pour obtenir le DC

C'est là que c'est un peu faux-cul : que prendre pour la valeur DC, alors que l'on voit très bien le peu de fiabilité qui existe pour cette valeur ?

Certes, pas besoin d'avoir des valeurs bien précises avec les approximations déjà faites, vu que numériquement, on fait un ratio des valeurs AC pour chaque longueur d'onde, mais également des valeurs DC. Mais bon, ensuite on applique une correction via une table de calibration, encore faut-il qu'elle corresponde bien aux caractéristiques du système...

J'ai donc facilité les calculs sur des entiers, avec une seule division, opération extrêmement coûteuse en temps de calcul sur un microcontrôleur. Puis j'applique la table fournie avec le MAX30101, qui est en fait l'application d'une formule de calcul empirique.

float_SPO2 = -45.060*ratio*ratio/10000 + 30.354 *ratio/100 + 94.845;

unsigned long ratio1, ratio2; // calcul du ratio 100*red/ir
ratio1 = 100 * LED_red.amplitude * (LED_ir.histo >>8);
ratio2 = LED_ir.amplitude * (LED_red.histo>>8);
if (ratio2) ratio = ratio1/ratio2;
SPo2=0;
if( ratio>2 && ratio<184) SPo2 = spo2_table_read((unsigned char) (ratio&0x000000FF) );

Vous remarquerez le soin apporté sur les calculs. Et la table évite des calculs flottants longs à exécuter. Surtout pour un résultat qui tient dans un octet.

Réalisation

Et une petite vidéo montrant le lancement et le fonctionnement de l'oxymètre sur ma carte de développement :

Remarquez le démarrage et la convergence rapide de l'algorithme qui élimine le continu.

Cette carte comporte de nombreux autres capteurs : accéléromètre, gyromètre, température, luminosité, humidité... Ils sont tous sur l'interface I2C.

Vous connaissez à présent les limites de ce que l'on peut faire soi-même, et c'est essentiellement la calibration qui est le plus compliqué, voire impossible pour l'amateur.

Page suivante, vous trouverez quelques références.