diff options
Diffstat (limited to 'src/components/heartrate/Ppg.cpp')
| -rw-r--r-- | src/components/heartrate/Ppg.cpp | 327 |
1 files changed, 256 insertions, 71 deletions
diff --git a/src/components/heartrate/Ppg.cpp b/src/components/heartrate/Ppg.cpp index 900b1c22..efbed852 100644 --- a/src/components/heartrate/Ppg.cpp +++ b/src/components/heartrate/Ppg.cpp @@ -1,107 +1,292 @@ -/* - SPDX-License-Identifier: LGPL-3.0-or-later - Original work Copyright (C) 2020 Daniel Thompson - C++ port Copyright (C) 2021 Jean-François Milants -*/ - #include "components/heartrate/Ppg.h" -#include <vector> #include <nrf_log.h> +#include <vector> + using namespace Pinetime::Controllers; -/** Original implementation from wasp-os : https://github.com/daniel-thompson/wasp-os/blob/master/wasp/ppg.py */ namespace { - int Compare(int8_t* d1, int8_t* d2, size_t count) { - int e = 0; - for (size_t i = 0; i < count; i++) { - auto d = d1[i] - d2[i]; - e += d * d; + float LinearInterpolation(const float* xValues, const float* yValues, int length, float pointX) { + if (pointX > xValues[length - 1]) { + return yValues[length - 1]; + } else if (pointX <= xValues[0]) { + return yValues[0]; + } + int index = 0; + while (pointX > xValues[index] && index < length - 1) { + index++; + } + float pointX0 = xValues[index - 1]; + float pointX1 = xValues[index]; + float pointY0 = yValues[index - 1]; + float pointY1 = yValues[index]; + float mu = (pointX - pointX0) / (pointX1 - pointX0); + + return (pointY0 * (1 - mu) + pointY1 * mu); + } + + float PeakSearch(float* xVals, float* yVals, float threshold, float& width, float start, float end, int length) { + int peaks = 0; + bool enabled = false; + float minBin = 0.0f; + float maxBin = 0.0f; + float peakCenter = 0.0f; + float prevValue = LinearInterpolation(xVals, yVals, length, start - 0.01f); + float currValue = LinearInterpolation(xVals, yVals, length, start); + float idx = start; + while (idx < end) { + float nextValue = LinearInterpolation(xVals, yVals, length, idx + 0.01f); + if (currValue < threshold) { + enabled = true; + } + if (currValue >= threshold and enabled) { + if (prevValue < threshold) { + minBin = idx; + } else if (nextValue <= threshold) { + maxBin = idx; + peaks++; + width = maxBin - minBin; + peakCenter = width / 2.0f + minBin; + } + } + prevValue = currValue; + currValue = nextValue; + idx += 0.01f; } - return e; + if (peaks != 1) { + width = 0.0f; + peakCenter = 0.0f; + } + return peakCenter; } - int CompareShift(int8_t* d, int shift, size_t count) { - return Compare(d + shift, d, count - shift); + float SpectrumMean(const std::array<float, Ppg::spectrumLength>& signal, int start, int end) { + int total = 0; + float mean = 0.0f; + for (int idx = start; idx < end; idx++) { + mean += signal.at(idx); + total++; + } + if (total > 0) { + mean /= static_cast<float>(total); + } + return mean; } - int Trough(int8_t* d, size_t size, uint8_t mn, uint8_t mx) { - auto z2 = CompareShift(d, mn - 2, size); - auto z1 = CompareShift(d, mn - 1, size); - for (int i = mn; i < mx + 1; i++) { - auto z = CompareShift(d, i, size); - if (z2 > z1 && z1 < z) { - return i; + float SignalToNoise(const std::array<float, Ppg::spectrumLength>& signal, int start, int end, float max) { + float mean = SpectrumMean(signal, start, end); + return max / mean; + } + + // Simple bandpass filter using exponential moving average + void Filter30to240(std::array<float, Ppg::dataLength>& signal) { + // From: + // https://www.norwegiancreations.com/2016/03/arduino-tutorial-simple-high-pass-band-pass-and-band-stop-filtering/ + + int length = signal.size(); + // 0.268 is ~0.5Hz and 0.816 is ~4Hz cutoff at 10Hz sampling + float expAlpha = 0.816f; + float expAvg = 0.0f; + for (int loop = 0; loop < 4; loop++) { + expAvg = signal.front(); + for (int idx = 0; idx < length; idx++) { + expAvg = (expAlpha * signal.at(idx)) + ((1 - expAlpha) * expAvg); + signal[idx] = expAvg; + } + } + expAlpha = 0.268f; + for (int loop = 0; loop < 4; loop++) { + expAvg = signal.front(); + for (int idx = 0; idx < length; idx++) { + expAvg = (expAlpha * signal.at(idx)) + ((1 - expAlpha) * expAvg); + signal[idx] -= expAvg; } - z2 = z1; - z1 = z; } - return -1; } -} -Ppg::Ppg() - : hpf {0.87033078, -1.74066156, 0.87033078, -1.72377617, 0.75754694}, - agc {20, 0.971, 2}, - lpf {0.11595249, 0.23190498, 0.11595249, -0.72168143, 0.18549138} { -} + float SpectrumMax(const std::array<float, Ppg::spectrumLength>& data, int start, int end) { + float max = 0.0f; + for (int idx = start; idx < end; idx++) { + if (data.at(idx) > max) { + max = data.at(idx); + } + } + return max; + } -int8_t Ppg::Preprocess(float spl) { - spl -= offset; - spl = hpf.Step(spl); - spl = agc.Step(spl); - spl = lpf.Step(spl); + void Detrend(std::array<float, Ppg::dataLength>& signal) { + int size = signal.size(); + float offset = signal.front(); + float slope = (signal.at(size - 1) - offset) / static_cast<float>(size - 1); - auto spl_int = static_cast<int8_t>(spl); + for (int idx = 0; idx < size; idx++) { + signal[idx] -= (slope * static_cast<float>(idx) + offset); + } + for (int idx = 0; idx < size - 1; idx++) { + signal[idx] = signal[idx + 1] - signal[idx]; + } + } - if (dataIndex < 200) { - data[dataIndex++] = spl_int; + // Hanning Coefficients from numpy: python -c 'import numpy;print(numpy.hanning(64))' + // Note: Harcoded and must be updated if constexpr dataLength is changed. Prevents the need to + // use cosf() which results in an extra ~5KB in storage. + // This data is symetrical so just using the first half (saves 128B when dataLength is 64). + static constexpr float hanning[Ppg::dataLength >> 1] { + 0.0f, 0.00248461f, 0.00991376f, 0.0222136f, 0.03926189f, 0.06088921f, 0.08688061f, 0.11697778f, + 0.15088159f, 0.1882551f, 0.22872687f, 0.27189467f, 0.31732949f, 0.36457977f, 0.41317591f, 0.46263495f, + 0.51246535f, 0.56217185f, 0.61126047f, 0.65924333f, 0.70564355f, 0.75f, 0.79187184f, 0.83084292f, + 0.86652594f, 0.89856625f, 0.92664544f, 0.95048443f, 0.96984631f, 0.98453864f, 0.99441541f, 0.99937846f}; +} + +Ppg::Ppg() { + dataAverage.fill(0.0f); + spectrum.fill(0.0f); +} + +int8_t Ppg::Preprocess(uint16_t hrs, uint16_t als) { + if (dataIndex < dataLength) { + dataHRS[dataIndex++] = hrs; } - return spl_int; + alsValue = als; + if (alsValue > alsThreshold) { + return 1; + } + return 0; } int Ppg::HeartRate() { - if (dataIndex < 200) { + if (dataIndex < dataLength) { return 0; } - - NRF_LOG_INFO("PREPROCESS, offset = %d", offset); - auto hr = ProcessHeartRate(); - dataIndex = 0; + int hr = 0; + hr = ProcessHeartRate(resetSpectralAvg); + resetSpectralAvg = false; + // Make room for overlapWindow number of new samples + for (int idx = 0; idx < dataLength - overlapWindow; idx++) { + dataHRS[idx] = dataHRS[idx + overlapWindow]; + } + dataIndex = dataLength - overlapWindow; return hr; } -int Ppg::ProcessHeartRate() { - int t0 = Trough(data.data(), dataIndex, 7, 48); - if (t0 < 0) { - return 0; +void Ppg::Reset(bool resetDaqBuffer) { + if (resetDaqBuffer) { + dataIndex = 0; } + avgIndex = 0; + dataAverage.fill(0.0f); + lastPeakLocation = 0.0f; + alsThreshold = UINT16_MAX; + alsValue = 0; + resetSpectralAvg = true; + spectrum.fill(0.0f); +} - int t1 = t0 * 2; - t1 = Trough(data.data(), dataIndex, t1 - 5, t1 + 5); - if (t1 < 0) { - return 0; +// Pass init == true to reset spectral averaging. +// Returns -1 (Reset Acquisition), 0 (Unable to obtain HR) or HR (BPM). +int Ppg::ProcessHeartRate(bool init) { + std::copy(dataHRS.begin(), dataHRS.end(), vReal.begin()); + Detrend(vReal); + Filter30to240(vReal); + vImag.fill(0.0f); + // Apply Hanning Window + int hannIdx = 0; + for (int idx = 0; idx < dataLength; idx++) { + if (idx >= dataLength >> 1) { + hannIdx--; + } + vReal[idx] *= hanning[hannIdx]; + if (idx < dataLength >> 1) { + hannIdx++; + } } - - int t2 = (t1 * 3) / 2; - t2 = Trough(data.data(), dataIndex, t2 - 5, t2 + 5); - if (t2 < 0) { - return 0; + // Compute in place power spectrum + ArduinoFFT<float> FFT = ArduinoFFT<float>(vReal.data(), vImag.data(), dataLength, sampleFreq); + FFT.compute(FFTDirection::Forward); + FFT.complexToMagnitude(); + FFT.~ArduinoFFT(); + SpectrumAverage(vReal.data(), spectrum.data(), spectrum.size(), init); + peakLocation = 0.0f; + float threshold = peakDetectionThreshold; + float peakWidth = 0.0f; + int specLen = spectrum.size(); + float max = SpectrumMax(spectrum, hrROIbegin, hrROIend); + float signalToNoiseRatio = SignalToNoise(spectrum, hrROIbegin, hrROIend, max); + if (signalToNoiseRatio > signalToNoiseThreshold && spectrum.at(0) < dcThreshold) { + threshold *= max; + // Reuse VImag for interpolation x values passed to PeakSearch + for (int idx = 0; idx < dataLength; idx++) { + vImag[idx] = idx; + } + peakLocation = PeakSearch(vImag.data(), + spectrum.data(), + threshold, + peakWidth, + static_cast<float>(hrROIbegin), + static_cast<float>(hrROIend), + specLen); + peakLocation *= freqResolution; } - - int t3 = (t2 * 4) / 3; - t3 = Trough(data.data(), dataIndex, t3 - 4, t3 + 4); - if (t3 < 0) { - return (60 * 24 * 3) / t2; + // Peak too wide? (broad spectrum noise or large, rapid HR change) + if (peakWidth > maxPeakWidth) { + peakLocation = 0.0f; } - - return (60 * 24 * 4) / t3; + // Check HR limits + if (peakLocation < minHR || peakLocation > maxHR) { + peakLocation = 0.0f; + } + // Reset spectral averaging if bad reading + if (peakLocation == 0.0f) { + resetSpectralAvg = true; + } + // Set the ambient light threshold and return HR in BPM + alsThreshold = static_cast<uint16_t>(alsValue * alsFactor); + // Get current average HR. If HR reduced to zero, return -1 (reset) else HR + peakLocation = HeartRateAverage(peakLocation); + int rtn = -1; + if (peakLocation == 0.0f && lastPeakLocation > 0.0f) { + lastPeakLocation = 0.0f; + } else { + lastPeakLocation = peakLocation; + rtn = static_cast<int>((peakLocation * 60.0f) + 0.5f); + } + return rtn; } -void Ppg::SetOffset(uint16_t offset) { - this->offset = offset; - dataIndex = 0; +void Ppg::SpectrumAverage(const float* data, float* spectrum, int length, bool reset) { + if (reset) { + spectralAvgCount = 0; + } + float count = static_cast<float>(spectralAvgCount); + for (int idx = 0; idx < length; idx++) { + spectrum[idx] = (spectrum[idx] * count + data[idx]) / (count + 1); + } + if (spectralAvgCount < spectralAvgMax) { + spectralAvgCount++; + } } -void Ppg::Reset() { - dataIndex = 0; +float Ppg::HeartRateAverage(float hr) { + avgIndex++; + avgIndex %= dataAverage.size(); + dataAverage[avgIndex] = hr; + float avg = 0.0f; + float total = 0.0f; + float min = 300.0f; + float max = 0.0f; + for (const float& value : dataAverage) { + if (value > 0.0f) { + avg += value; + if (value < min) + min = value; + if (value > max) + max = value; + total++; + } + } + if (total > 0) { + avg /= total; + } else { + avg = 0.0f; + } + return avg; } |
