Pulssensor (Teil 3)

Module Biometrie

Wie versprochen, werden wir uns dieses Mal anschauen, wie die Pulsmessung auch ohne Puffer implementiert werden kann.

Pulsmessung ohne Puffer

Im letzten Teil dieser Tutorialserie zum KY-039 Herzfrequenzsensor haben wir bereits einen Weg gefunden, die Herzfrequenz zu berechnen. Allerdings habe ich den letzten Teil mit einer Herausforderung beendet: eine Lösung zu finden, die ohne Puffer funktioniert. Hattest du Erfolg? In diesem dritten und letzten Teil werde ich dir meine Lösung zeigen.

Hier ist sie:

// Constants
const float rThreshold = 0.7;
const float decayRate = 0.01;
const float thrRate = 0.05;
const int minDiff = 5;

// Current values
float maxValue = 0;
float minValue = 1024;
float threshold = 512;

// Timestamp of the last heartbeat
long lastHeartbeat = 0;

// Last value to detect crossing the threshold
int lastValue = 1024;

void setup() {
  Serial.begin(9600);
}
  
void loop() {
  int currentValue = analogRead(A0);
  maxValue = max(maxValue, currentValue);
  minValue = min(minValue, currentValue);

  // Detect Heartbeat
  float nthreshold = (maxValue - minValue) * rThreshold + minValue;
  threshold = threshold * (1-thrRate) + nthreshold * thrRate;
  threshold = min(maxValue, max(minValue, threshold));
  
  if(currentValue >= threshold 
      && lastValue < threshold 
      && (maxValue-minValue) > minDiff 
      && millis() - lastHeartbeat > 300) {
        
    if(lastHeartbeat != 0) {
      // Show Results
      int bpm = 60000/(millis() - lastHeartbeat);
      if(bpm > 50 && bpm < 250) {
        Serial.print("Heart Rate (bpm): ");
        Serial.println(bpm);
      }
    }
    lastHeartbeat = millis();
  }

  // Decay for max/min
  maxValue -= (maxValue-currentValue)*decayRate;
  minValue += (currentValue-minValue)*decayRate;

  lastValue = currentValue;
  delay(20);
}

Funktionsweise

Nun, das ist eine Menge Code, aber wie funktioniert er? Die Grundidee bleibt die gleiche wie im vorherigen Teil dieses Tutorials. Wir verwenden einen relativen Schwellenwert bei 70 % zwischen dem Minimum und dem Maximum der aufgezeichneten Daten, um die Herzschläge zu erkennen. Ohne einen Puffer können wir allerdings nicht mehr einfach über alle Werte iterieren, um das Minimum oder Maximum zu bestimmen und den Schwellenwert zu berechnen. Ich habe nach einer Alternative gesucht und beschlossen, stattdessen eine Näherung für diese Werte zu verwenden.

Eine Näherung für Minimum und Maximum

Wie kann man den Minimal- und Maximalwert näherungsweise bestimmen? Nun, es ist nicht schwer, das absolute Maximum und Minimum zu bestimmen. Wir müssen lediglich den aktuellen Höchst- und Tiefstwert in einer Variablen speichern. Jedes Mal, wenn ein höherer oder niedrigerer Wert auftritt, aktualisieren wir diese Variablen entsprechend. Dafür sorgen die folgenden Codezeilen:

int currentValue = analogRead(A0);
maxValue = max(maxValue, currentValue);
minValue = min(minValue, currentValue);

Das Problem ist, dass wir nicht das Allzeithoch und -tief benötigen, sondern das Minimum und Maximum in einem begrenzten Zeitrahmen. Um uns dem anzunähern, können wir einen Trick anwenden: Wir verringern den Höchstwert und erhöhen den Tiefstwert mit der Zeit. Wenn der Höchstwert immer wieder erreicht wird, wird die Variable wiederholt auf diesen Wert gesetzt und bleibt hoch. Wird er jedoch längere Zeit nicht erreicht, wird das gespeicherte Maximum immer weiter verringert. Analog dazu wird das Minimum immer weiter erhöht, bis es an die aktuellen Werte angepasst ist.

Damit diese Methode gut funktioniert, müssen wir einen geeigneten Weg wählen, um Minimum und Maximum zu erhöhen und zu verringern. Wenn wir die Werte zu schnell anpassen, verlieren die gespeicherten Werte ihren Nutzen, da sie sich zu schnell dem aktuellen Wert annähern. Werden sie dagegen zu langsam angepasst, erhöht sich die Reaktionszeit unserer Herzschlagerkennung und wir müssen lange warten, bis die Herzfrequenz richtig berechnet wird. Was wir brauchen, ist ein guter Kompromiss zwischen beiden.

In meiner Lösung kann die Rate, mit der Maximum und Minimum an den aktuellen Wert angepasst werden, mit der Konstante decayRate konfiguriert werden. Die decayRate wird dann mit der Differenz zwischen dem gespeicherten Maximum oder Minimum und dem aktuellen Wert multipliziert. Das heißt, wenn die Differenz groß ist, werden Maximum und Minimum schneller angepasst als wenn sie klein ist. Dies gewährleistet eine schnelle Anpassung, wenn sich der Wertebereich stark ändert und funktioniert auch dann noch, wenn die Amplitude des gemessenen Signals gering ist.

// Decay for max/min
maxValue -= (maxValue-currentValue)*decayRate;
minValue += (currentValue-minValue)*decayRate;

Wie stark sich der Messwert bei einem Herzschlag ändert, hängt stark vom Sensor, der verwendeten Messmethode und der Frage ab, ob das Modul eine Signalverstärkung durchführt. Insbesondere die Version des KY-039, die eine Infrarot-LED verwendet, um durch den Finger zu leuchten, hat eine sehr kleine Signalamplitude, bei der sich der Wert nur um etwa 10 bis 20 Ticks ändert. Andere Modulversionen, wie diejenige, die zur Erstellung der in diesem und den vorherigen Teilen dieses Tutorials gezeigten Diagramme benutzt wurde, haben eine wesentlich größere Amplitude. Der Wert ändert sich um mehrere hundert Ticks. Dadurch, dass kein fester Wert zum Erhöhen und Verringern von Minimum und Maximum verwendet wird, funktioniert der Code für beide Versionen des Moduls.

Glättung des Schwellwerts

Mithilfe von Minimum und Maximum können wir nun einen Schwellenwert berechnen, ganz so wie wir es zuvor getan haben. Wenn wir dies tun, wird der Schwellwert jedoch stark variieren, da Maximum und Minimum ständig variieren. Um dies zu kompensieren, verwendet der Code einen Glättungsalgorithmus. Der dabei verwendete Algorithmus heißt exponentielle Glättung.

Die Idee ist einfach: Wir ersetzen den Schwellenwert nicht bei jeder Iteration, sondern verwenden einen Teil des alten Wertes und einen Teil des neuen Wertes, um den nächsten Schwellenwert zu berechnen. Dies wird durch die folgenden Codezeilen erreicht:

float nthreshold = (maxValue - minValue) * rThreshold + minValue;
threshold = threshold * (1-thrRate) + nthreshold * thrRate;
threshold = min(maxValue, max(minValue, threshold));

Mit der Konstante thrRate kann festgelegt werden, wie schnell der Schwellwert angepasst wird. Je höher der Wert, desto schneller. Wenn die Rate klein ist, ist es möglich, dass sich der Schwellenwert so langsam anpasst, dass er nicht mehr zwischen Maximum und Minimum liegt. Die dritte Codezeile kümmert sich um dieses Problem und sorgt dafür, dass der Schwellenwert immer zwischen Minimum und Maximum bleibt.

Der Algorithmus in Aktion

Genug der Theorie. Sehen wir uns den Algorithmus in Aktion an. Dazu habe ich eine kleine Visualisierung erstellt. Die Grafik unten zeigt etwa 5 Sekunden an aufgezeichneten Sensordaten. Maximum und Minimum werden durch die blaue Linie dargestellt, während der Schwellwert in Grün angezeigt wird. Jeder der roten Punkte markiert einen erkannten Herzschlag. Bitte beachte, dass der Herzschlag immer erst dann erkannt wird, wenn der Wert den Schwellwert bereits überschritten hat. Dies ist kein Fehler. Der Grund für dieses Verhalten ist, dass wir nur alle 20 ms eine Messung durchführen. Bei der nächsten Messung nach Überschreiten des Schwellenwerts ist der gemessene Wert bereits viel höher als der Schwellwert selbst.

Wenn du möchtest, kannst du die Visualisierung mit deinen eigenen Daten ausprobieren. Nutze dazu den Code unter "Messprogramm", um einige Daten aufzuzeichnen. Diese Daten kannst du dann vom seriellen Monitor in das Textfeld im Reiter "Daten" kopieren. Das Diagramm zeigt anschließend deine eigenen Daten an, und bei Bedarf kannst du auch die Konstanten für den Algorithmus zur Herzschlagerkennung anpassen. In den meisten Fällen sollte es jedoch direkt funktionieren.

Relativer Schwellwert (%)
Mindestdifferenz
Anpassungsrate für Minimum und Maximum
Anpassungsrate für den Schwellwert

Das Ergebnis

Funktioniert das Programm auch in der Praxis? Ja, es funktioniert. In der folgenden Abbildung werden die Messungen im seriellen Monitor angezeigt. Ausgabe im seriellen Monitor

Ist diese Lösung besser als die vorherigen? Nun, das hängt davon ab, was für dich wichtig ist. Die neue Lösung benötigt definitiv weniger Rechenressourcen, aber seien wir mal ehrlich: Solange alles auf den Arduino passt, wen interessiert das schon? Was wirklich zählt, sind Genauigkeit, Reaktionszeit und die Stabilität der Herzschlagerkennung. Wenn wir dies vergleichen, gibt es keinen klaren Gewinner.

Die neue Lösung hat eine etwas schnellere Reaktionszeit, da nicht erst ein Puffer gefüllt werden muss. Bei der Herzschlagerkennung ist die Wahrscheinlichkeit, dass ein Herzschlag übersehen wird, geringer, da sich der Schwellenwert schneller an Änderungen des ausgeübten Drucks oder der Position des Fingers anpasst. Allerdings sind die ausgegebenen Herzfrequenzwerte nicht mehr so stabil wie zuvor. Die Kehrseite der neuen Lösung ist, dass es wahrscheinlicher ist, dass sie Herzschläge falsch erkennt und dann falsche Werte ausgibt. Dies lässt sich wahrscheinlich durch eine Anpassung des Schwellenwerts beheben. Allerdings gibt es auch mehr Schwankungen zwischen den einzelnen Messungen. Der Grund dafür ist einfach: Die alte Lösung bildete immer einen Mittelwert über mehrere Herzschläge, während die neue Lösung die Herzfrequenz anhand der Zeitdifferenz zwischen lediglich zwei Herzschlägen bestimmt. Dadurch wurde der Einfluss von Messfehler geringer und die Variabilität in unseren Messungen verdeckt.

Eine Ursache für die Variabilität ist die niedrige Samplingrate. In den vorherigen Beispielen haben wir die Herzschläge gezählt. Bei dieser neuen Lösung messen wir die Zeit zwischen zwei Herzschlägen und extrapolieren diesen Wert auf einen Wert für die Schläge pro Minute. Hierfür benötigen wir eine viel höhere Samplingrate. Bei dem derzeitigen Messintervall von 20 ms müssen wir mit einer Varianz von bis zu ± 40 ms rechnen. Bei einer Herzfrequenz von 60 Schlägen pro Minute entspricht dies einer Streuung von ± 3 Schlägen pro Minute. Dies ist die Variabilität, die bereits allein durch unsere derzeitige Messtechnik verursacht wird.

Ohne Mittelwertbildung werden wir jedoch nie eine stabile Pulsmessung erhalten. Der Grund dafür ist, dass es eine natürliche Varianz der Zeitdauer zwischen den einzelnen Herzschlägen gibt. Das heißt, wir können nicht erwarten, dass die Messungen alle gleich sind. Dies ist eine sehr interessante Tatsache, die einen leicht dazu verleiten kann, zu glauben, dass die Messungen nicht korrekt sind. Auf der anderen Seite ist die Herzfrequenzvariabilität aber auch ein sehr interessantes Forschungsgebiet. Wenn du den seriellen Plotter öffnest, wirst du wahrscheinlich sehen, dass die Herzfrequenz synchron mit deinem Atem ansteigt und abfällt. Wenn du mehr darüber wissen willst, kannst du dir die folgenden zwei Arbeiten ansehen:

Es gibt noch viel was man verbessern kann, insbesondere mit dem richtigen Wissen über Signalverarbeitung. Wenn du sehen willst, wie eine professionellere Herzfrequenzerkennung implementiert werden kann, empfehle ich dir die folgende theoretische Abhandlung von Foroozan und Wu von Analog Devices:

Wenn du mit einfacheren Verbesserungen beginnen willst, kannst du zunächst die Samplingrate erhöhen. Hierfür musst du allerdings auch die decayRate anpassen. Wenn du stabile Ausgabewerte willst, verwende ein Verfahren zur Mittelwertbildung wie die exponentielle Glättung oder den gleitenden Mittelwert, den wir beim letzten Mal betrachtet haben. Auf diese Weise tauschen wir die schnelle Reaktionszeit gegen stabilere Ausgabewerte ein. Die endgültige Lösung ist immer ein Kompromiss.

Für welchen Weg du dich auch entscheidest, gib nicht zu früh auf. Du kannst es schaffen. Aber Achtung: Eine kompliziertere Lösung ist nicht unbedingt eine bessere.

Vorheriger Beitrag Nächster Beitrag