Pulssensor (Teil 2)

Module Biometrie

Im letzten Teil dieses Tutorials haben wir bereits das KY-039 Modul ausprobiert. Heute werden wir die Pulsberechnung implementieren.

Berechnung der Herzfrequenz

Die KY-039 Pulssensoren sind einfach gebaut und das Messprinzip selbst ist ebenfalls nicht kompliziert. Die Berechnung der Herzfrequenz mit einem Arduino ist es jedoch nicht, weshalb viele lieber Pulssensoren verwenden, für die bereits eine Arduino Bibliothek existiert, mit der Herzfrequenz direkt ausgeben werden kann. Wie können wir selbst den Puls anhand der Messungen unseres Sensors bestimmen?

Auf den ersten Blick scheint es ganz einfach zu sein: Man zählt einfach eine bestimmte Zeit lang die Herzschläge und rechnet das Ergebnis dann in Schläge pro Minute um. Es ist wirklich so einfach. Jetzt müssen wir es nur noch in Code umsetzen. Aber wie? An dieser Stelle wird es knifflig.

Wir messen nicht die Herzfrequenz, sondern die Menge des Blutes in den Blutgefäßen. Selbst das ist nicht ganz richtig, wir messen nur einen Wert, der grob mit der Menge des Blutes in unseren Blutgefäßen korreliert und uns so ermöglicht, den Herzschlag zu erkennen. Alle Werte, die wir messen, sind weder kalibriert noch reproduzierbar. Jedes Mal, wenn wir den Finger auf den Sensor legen, unterscheiden sich die Werte, da der Druck, den wir ausüben, und die genaue Position des Fingers unterschiedlich sind.

Dennoch ist die ursprüngliche Idee richtig und funktioniert bei manueller Anwendung einwandfrei. Für die automatisierte Anwendung müssen wir jedoch zwei Probleme lösen: die Erkennung der Herzschläge und die Aggregation der Daten über die Zeit. In diesem zweiten Teil zu den Pulssensoren werden nach Lösungen für diese Probleme suchen.

Herzschlagerkennung

Beginnen wir mit dem ersten Problem: Wie können wir Herzschläge erkennen? Wenn wir die beschriebene Methode manuell anwenden, sind wir sofort in der Lage zu erkennen, welche Peaks in unseren aufgezeichneten Daten Herzschläge sind und welche nicht. Wir sind Meister der Mustererkennung, Computer sind es nicht. Computer führen mathematische Operationen durch. Es ist jedoch möglich, unsere menschlichen Fähigkeiten zur Mustererkennung durch mathematische Operationen zu imitieren. Hier entfalten neuronale Netze und Algorithmen des maschinellen Lernens ihr volles Potenzial. Sie können auf leistungsfähigeren Plattformen zur Lösung dieses Problems eingesetzt werden. Für den Arduino Uno sind sie jedoch keine gute Lösung.

Für den Arduino müssen wir unser Problem so vereinfachen, dass es mit einfachen mathematischen Methoden lösbar ist. Wir wollen gar nicht überprüfen, ob unsere Herzschläge regelmäßig auftreten und wir wollen auch keine Anomalien erkennen. Wir können voraussetzen, dass der Finger richtig positioniert ist. Nur wenn das der Fall ist, dann wollen wir einen korrekten Wert für unsere Herzfrequenz. Um dies zu erreichen, sollte eine viel einfachere mathematische Methode ausreichen.

Wie kann dies aussehen? Nun, wir können einen Schwellwert verwenden und sagen, dass jedes Mal, wenn wir diesen Schwellwert überschreiten, dies auf einen Herzschlag zurückzuführen ist. Das ist natürlich nicht zwangsläufig richtig, aber für unseren Fall ist es eine ausreichend gute Näherung. Das Problem ist, dass ein einfacher Schwellwert nicht funktioniert, weil wir jedes Mal neu kalibrieren müssten, wenn wir unseren Finger bewegen oder den Druck verändern. Was wir brauchen, ist ein dynamischer Schwellenwert.

Herzschlagerkennung mittels eines Schwellenwerts

Wie funktioniert das Ganze? Die Idee ist ähnlich wie bei einer Kalibrierung: Wir bestimmen den Maximal- und Minimalwert und legen dann den Schwellwert dazwischen fest. Da wir den Schwellwert nicht hartkodieren wollen, definieren wir ihn als relativen Wert, z. B. 70 % zwischen dem Minimal- und dem Maximalwert. Wir könnten eine Kalibrierung während der Initialisierung unseres Programms durchführen, aber das wäre nicht sehr dynamisch, nicht wahr?

Um die Herzschläge zu zählen und die Herzfrequenz zu bestimmen, benötigen wir ohnehin eine ganze Sammlung an Messwerten. Für unseren dynamischen Schwellenwert berechnen wir einfach jedes Mal das Maximum und Minimum neu, wenn wir ein anderes Zeitfenster mit neuen Messwerten betrachten. Wenn wir unsere Fingerposition ändern, erhalten wir möglicherweise zunächst einen falschen Wert, aber nach kurzer Zeit sollte der richtige Schwellenwert ermittelt sein. Wir können damit beginnen, die Zeit zwischen den Herzschlägen zu messen und daraus die Herzfrequenz zu berechnen.

Datenaggregation

Bevor wir die Herzfrequenz berechnen können, müssen wir jedoch erst einmal genügend Daten sammeln. Wir können die Herzfrequenz nicht aus einem einzigen Messwert berechnen. In unserem Programm werden wir einen Puffer verwenden, der Messwerte für ein Zeitfenster von 5 s sammelt.

Die Verwendung von Zeitfenstern ist eine gängige Praxis bei der Arbeit mit Zeitreihendaten. Es gibt verschiedene Methoden, um ein solches Zeitfenster zu definieren. In diesem Tutorial werden wir uns zwei davon ansehen: Tumbling Window und Sliding Window.

Tumbling Window

Das Tumbling Window ist wahrscheinlich eine der einfachsten Methoden, um mit einer Folge von Messwerten zu arbeiten. Wir sammeln Daten für eine bestimmte Zeit und verarbeiten sie dann. Wenn wir fertig sind, beginnen wir von vorne und zeichnen den nächsten Datenblock auf. Diese Strategie ist in der nachfolgenden Abbildung dargestellt.

Datenerfassung und -verarbeitung mit einem Tumbling Window

Werfen wir einen Blick auf den Code:

// Constants
const float rThreshold = 0.7;
const int minDiff = 10;

// Sample Buffer
const int N_SAMPLES = 250;
int sampleBuffer[N_SAMPLES];

void setup() {
  Serial.begin(9600);
}

void loop() {
  int maxValue = 0;
  int minValue = 1024;
  
  // Wait for values and calculate maximum and minimum
  for(int i = 0; i < N_SAMPLES; i++) {
    sampleBuffer[i] = analogRead(A0);
    maxValue = max(sampleBuffer[i], maxValue);
    minValue = min(sampleBuffer[i], minValue);
    
    delay(20);
  }

  float diff = max(maxValue - minValue, minDiff);
  float threshold = diff * rThreshold + minValue;
  int nHeartbeats = 0;
  int tHeartbeats = 0;
  int lastHeartbeat = 0;
  
  for(int i = 1; i < N_SAMPLES; i++) {
    if(sampleBuffer[i] >= threshold && sampleBuffer[i-1] < threshold) {
      // Save time difference and increase counter
      // Don't count time difference if it is too short or too long
      // - 15 measurements correspond to roughly 250 bpm
      // - 150 measurements correspond to roughly 25 bpm
      if(lastHeartbeat && i-lastHeartbeat > 15 && i-lastHeartbeat < 150) {
        tHeartbeats += i-lastHeartbeat; 
        nHeartbeats++;
      }

      // Save timestamp
      lastHeartbeat = i;
    }
  }

  // Calculate bpm
  float bpm = 60000.0 * nHeartbeats / (tHeartbeats * 20);
  
  // Show results if enough heartbeats are found
  // In 5 s there should be 4 at 50 bpm
  if(nHeartbeats > 3) {
    Serial.print("Heart Rate (bpm): ");
    Serial.println(bpm);
  }
  else {
    Serial.println("No heart beat detected");
  }
}

Das Programm verwendet einen Puffer, der Platz für 250 Messwerte bietet. Da alle 20 ms eine neue Messung durchgeführt wird, ist dieser Puffer groß genug, um fünf Sekunden an Daten zu speichern. In einem ersten Schritt wird der Puffer mit Messwerten des analogen Eingangs A0 gefüllt. Gleichzeitig wird der Minimal- und Maximalwert berechnet.

In einem zweiten Schritt wird der Schwellwert berechnet. Hierfür wird durch die Konstante rThreshold der relative Schwellwert von 70 % festgelegt. Diese Konstante wird dann mit dem zuvor ermittelten Differenz zwischen Minimal- und Maximalwert multipliziert. Um zu verhindern, dass kleinste Veränderungen ebenfalls als Herzschlag erkannt werden, wird die Konstante minDiff verwendet, um einen sinnvollen Wertebereich zu gewährleisten. So ist es möglich, zumindest ansatzweise zu erkennen, ob ein Finger auf dem Sensor liegt oder nicht.

Der dritte Schritt ist die Berechnung der Herzfrequenz. Dazu werden der jeweils letzte und der aktuelle Wert verglichen, um zu prüfen, ob der Schwellenwert überschritten wurde. Ist dies der Fall, wird die Zeitdifferenz zum letzten erkannten Herzschlag berechnet und zur Variable tHeartbeats addiert. Die Anzahl der erkannten Herzschläge wird in der Variablen nHeartbeats gespeichert. Diese beiden Variablen ermöglichen es, später die durchschnittliche Zeit zwischen den Herzschlägen zu berechnen. Dieser Wert wird benötigt, um daraus die Herzfrequenz in Schlägen pro Minute (bpm) zu berechnen, die auf dem seriellen Monitor angezeigt wird.

Zusätzlich zu den Berechnungen enthält das Programm einige Plausibilitätsprüfungen, die versuchen, falsche Messungen zu eliminieren. Herzschläge werden nur gezählt, wenn die Zeitdifferenz eine Herzfrequenz zwischen 50 und 250 bpm ergibt. Wenn nicht genügend gültige Herzschläge erkannt werden, wird keine Herzfrequenz ausgegeben, sondern die Meldung No heart beat detected angezeigt. Obwohl diese Überprüfungen die Richtigkeit der ausgegebenen Daten nicht garantieren können, verhindern sie zumindest die Anzeige von total falschen Daten, wenn beispielsweise der Finger bewegt wird oder gar nicht auf dem Sensor liegt.

Sliding Window

Bei der Tumbling-Window-Methode müssen wir für jede Pulsberechnung fünf Sekunden warten. Dies kann mit einem gleitenden Zeitfenster (Sliding Window) vermieden werden. Wie in der Abbildung unten dargestellt, verwenden wir bei dieser Methode überlappende Zeitfenster. Auf diese Weise können wir die Herzfrequenz stets anhand einer Kombination aus neuen und alten Messungen berechnen.

Datenerfassung und -verarbeitung mit einem gleitenden Fenster

Ein Sliding Window kann mit einem Ringpuffer implementiert werden. Für den Ringpuffer können wir das gleiche Array wie zuvor verwenden. Der Unterschied besteht darin, dass wir den Puffer nicht jedes Mal von Anfang bis Ende neu füllen. Bei der Verwendung eines Ringpuffers wird der Index, in den geschrieben werden soll, in einer separaten Variablen gespeichert. Sobald der Puffer gefüllt ist, beginnt der Index wieder bei 0. Sobald dies geschieht, werden alte Daten überschrieben, aber wir brauchen nicht zu warten, bis der Puffer vollständig überschrieben ist, um unsere nächste Berechnung durchzuführen. Wir können den gespeicherten Index verwenden, um den jeweils ältesten Messwert zu ermitteln und unsere Berechnung von dort aus zu starten.

Theoretisch könnten wir jedes Mal eine neue Berechnung durchführen, wenn wir eine neue Messung durchführen. In der Praxis ist dies nicht sinnvoll, da es Zeit braucht, bis der nächste Herzschlag erfolgt und sich der Wert für die Herzfrequenz ändert. Es reicht aus, die Berechnung nur ab und zu durchzuführen. Im Beispielprogramm habe ich mich dafür entschieden, dies alle 500 ms zu tun.

Hier ist der Code für dieses Programm:

// Constants
const float rThreshold = 0.7;
const int minDiff = 10;

// Sample Buffer
const int N_SAMPLES = 250;
int nSamples = 0;
int sampleBuffer[N_SAMPLES];
int index = 0;

// Last time the bpm was shown
long lastTime;

void setup() {
  Serial.begin(9600);
}

void loop() {
  sampleBuffer[index] = analogRead(A0);

  // Show results every 500 ms once the buffer is filled
  if(nSamples >= N_SAMPLES && millis()-lastTime > 500) {
    lastTime = millis();
    
    int maxValue = 0;
    int minValue = 1024;
    
    // Calculate maximum and minimum
    for(int i = 0; i < N_SAMPLES; i++) {
      maxValue = max(sampleBuffer[i], maxValue);
      minValue = min(sampleBuffer[i], minValue); 
    }
  
    float diff = max(maxValue - minValue, minDiff);
    float threshold = diff * rThreshold + minValue;
    int nHeartbeats = 0;
    int tHeartbeats = 0;
    int lastHeartbeat = 0;
    
    for(int i = 1; i < N_SAMPLES; i++) {
      if(sampleBuffer[(index+i+1)%N_SAMPLES] >= threshold 
          && sampleBuffer[(index+i)%N_SAMPLES] < threshold) {
        // Save time difference and increase counter
        // Don't count time difference if it is too short or too long
        // - 15 measurements correspond to roughly 250 bpm
        // - 150 measurements correspond to roughly 25 bpm
        if(lastHeartbeat && i-lastHeartbeat > 15 && i-lastHeartbeat < 150) {
          tHeartbeats += i-lastHeartbeat; 
          nHeartbeats++;
        }
  
        // Save timestamp
        lastHeartbeat = i;
      }
    }
  
    // Calculate bpm
    float bpm = 60000.0 * nHeartbeats / (tHeartbeats * 20);
    
    // Show results if enough heartbeats are found
    // In 5 s there should be 4 at 50 bpm
    if(nHeartbeats > 3) {
      Serial.print("Heart Rate (bpm): ");
      Serial.println(bpm);
    }
    else {
      Serial.println("No heart beat detected");
    }
  }
  else {
    nSamples++;
  }
  
  // Next Index
  index = (index+1) % N_SAMPLES;
  delay(20);
}

Das Ergebnis

Mit beiden Methoden ist es möglich, die Herzfrequenz zuverlässig zu berechnen. Bei der Verwendung eines Sliding Window erhalten wir darüber hinaus den Vorteil eines schnelleren Feedbacks und können Veränderungen in der Herzfrequenz beobachten, ohne fünf Sekunden auf die neuen Messungen warten zu müssen. Der Beobachtungszeitraum beträgt jedoch bei beiden Methoden fünf Sekunden. Aus diesem Grund dauert es seine Zeit, bis man das erste Ergebnis erhält, nachdem man den Finger auf den Sensor gelegt hat. Um eine schnellere Reaktionszeit zu erreichen, kannst du das Zeitfenster verkleinern, allerdings auf Kosten von sprunghafteren und weniger genauen Herzfrequenzmessungen.

Ergebnis auf dem seriellen Monitor

Sowohl die Tumbling-Window- als auch die Sliding-Window-Methode sind in Literatur und Praxis hinlänglich bekannt. Eine interessante Frage ist jedoch, ob wir all dies auch ohne einen Puffer zum Speichern unserer Messungen tun können. In unserem Beispiel ist dies nicht wichtig, aber es gibt Fälle, in denen dies unerlässlich ist. Was wäre, wenn wir für unsere Berechnungen mehr Messwerte bräuchten, als wir im Speicher des Arduino unterbringen könnten? Wenn wir ein paar Kompromisse eingehen, können wir die Herzfrequenz auch ohne Zwischenspeichern der Messwerte berechnen. Probiere doch mal aus, eine eigene Lösung für dieses Problem zu finden. Es ist eine gute Programmier- und Denkübung. Ich werde dir meine Lösung im nächsten und letzten Teil dieser Tutorialreihe zum KY-039 Pulssensor zeigen.

Vorheriger Beitrag Nächster Beitrag