feat: implement Haptika PPG and Watch V1
This commit is contained in:
@@ -0,0 +1,143 @@
|
||||
import Foundation
|
||||
|
||||
public struct PRVCalculationResult: Equatable, Sendable {
|
||||
public var metrics: PRVMetrics
|
||||
public var intervals: [PulseInterval]
|
||||
|
||||
public init(metrics: PRVMetrics, intervals: [PulseInterval]) {
|
||||
self.metrics = metrics
|
||||
self.intervals = intervals
|
||||
}
|
||||
}
|
||||
|
||||
public enum PPGPRVCalculator {
|
||||
public static func calculate(
|
||||
intervals inputIntervals: [PulseInterval],
|
||||
configuration: PPGProcessingConfiguration
|
||||
) throws -> PRVCalculationResult {
|
||||
let configuration = try configuration.validated()
|
||||
var intervals = inputIntervals
|
||||
let minimumInterval = 1_000 / configuration.maximumFrequencyHz
|
||||
let maximumInterval = 1_000 / configuration.minimumFrequencyHz
|
||||
|
||||
for index in intervals.indices where intervals[index].rejectionReason == nil {
|
||||
let value = intervals[index].durationMilliseconds
|
||||
if !value.isFinite || value < minimumInterval || value > maximumInterval {
|
||||
intervals[index].rejectionReason = .outsidePhysiologicalRange
|
||||
}
|
||||
}
|
||||
|
||||
// Filter against a moving neighbourhood (±13 intervals), not one global
|
||||
// median. This follows gradual physiological changes while isolating a
|
||||
// missed/double peak or a short movement artifact. Decisions use a frozen
|
||||
// snapshot so one rejected beat cannot cascade into later rejections.
|
||||
let initiallyAccepted = Set(intervals.indices.filter { intervals[$0].isAccepted })
|
||||
if initiallyAccepted.count >= 3 {
|
||||
for index in intervals.indices where initiallyAccepted.contains(index) {
|
||||
let lower = max(0, index - 13)
|
||||
let upper = min(intervals.count - 1, index + 13)
|
||||
let localValues = (lower...upper).compactMap { neighbour -> Double? in
|
||||
guard neighbour != index, initiallyAccepted.contains(neighbour) else { return nil }
|
||||
return intervals[neighbour].durationMilliseconds
|
||||
}
|
||||
guard localValues.count >= 2 else { continue }
|
||||
let center = SignalStatistics.median(localValues)
|
||||
let robustDeviation = 1.4826 * SignalStatistics.mad(localValues, around: center)
|
||||
let allowedDeviation = max(center * 0.20, 3.5 * robustDeviation, 70)
|
||||
let value = intervals[index].durationMilliseconds
|
||||
let resemblesMissedBeat = abs(value - 2 * center) <= max(0.18 * center, 70)
|
||||
let resemblesDoublePeak = abs(2 * value - center) <= max(0.18 * center, 70)
|
||||
if abs(value - center) > allowedDeviation
|
||||
|| resemblesMissedBeat
|
||||
|| resemblesDoublePeak {
|
||||
intervals[index].rejectionReason = .localOutlier
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let accepted = intervals.filter(\.isAccepted)
|
||||
let values = accepted.map(\.durationMilliseconds)
|
||||
let rejectedCount = intervals.count - accepted.count
|
||||
let rejectedRatio = intervals.isEmpty
|
||||
? 1
|
||||
: Double(rejectedCount) / Double(intervals.count)
|
||||
|
||||
let median = values.isEmpty ? nil : SignalStatistics.median(values)
|
||||
let mean = values.isEmpty ? nil : SignalStatistics.mean(values)
|
||||
let sdnn = values.count >= 2 ? SignalStatistics.standardDeviation(values) : nil
|
||||
let coefficientOfVariation: Double?
|
||||
if let median, let sdnn, median > 0 {
|
||||
coefficientOfVariation = sdnn / median
|
||||
} else {
|
||||
coefficientOfVariation = nil
|
||||
}
|
||||
|
||||
var successiveDifferences: [Double] = []
|
||||
for pair in zip(accepted, accepted.dropFirst()) {
|
||||
// Do not bridge over a rejected or missing beat: it would create a fictitious PRV difference.
|
||||
guard pair.1.sourceIndex == pair.0.sourceIndex + 1 else { continue }
|
||||
let difference = pair.1.durationMilliseconds - pair.0.durationMilliseconds
|
||||
successiveDifferences.append(difference)
|
||||
}
|
||||
let squaredSuccessiveDifferences = successiveDifferences.map { $0 * $0 }
|
||||
let rmssd = squaredSuccessiveDifferences.isEmpty
|
||||
? nil
|
||||
: SignalStatistics.mean(squaredSuccessiveDifferences).squareRoot()
|
||||
let pnn50Percent = successiveDifferences.isEmpty
|
||||
? nil
|
||||
: 100 * Double(successiveDifferences.filter { abs($0) > 50 }.count)
|
||||
/ Double(successiveDifferences.count)
|
||||
let rangeIpiMs: Double? = values.count >= 2
|
||||
? (values.max() ?? 0) - (values.min() ?? 0)
|
||||
: nil
|
||||
let histogram = Dictionary(grouping: values) { Int(floor($0 / 50)) }
|
||||
let modalBin = histogram.max { lhs, rhs in
|
||||
if lhs.value.count == rhs.value.count { return lhs.key > rhs.key }
|
||||
return lhs.value.count < rhs.value.count
|
||||
}
|
||||
let modeIpiMs = modalBin.map { SignalStatistics.mean($0.value) }
|
||||
let amo50Percent = modalBin.map {
|
||||
100 * Double($0.value.count) / Double(values.count)
|
||||
}
|
||||
|
||||
let reliable = values.count >= configuration.minimumIntervalsForPRV
|
||||
&& rejectedRatio <= configuration.maximumRejectedIntervalRatioForPRV
|
||||
&& rmssd != nil
|
||||
&& sdnn != nil
|
||||
|
||||
let metrics = PRVMetrics(
|
||||
estimatedRmssdMs: rmssd,
|
||||
estimatedSdnnMs: sdnn,
|
||||
meanIpiMs: mean,
|
||||
medianIpiMs: median,
|
||||
pnn50Percent: pnn50Percent,
|
||||
rangeIpiMs: rangeIpiMs,
|
||||
modeIpiMs: modeIpiMs,
|
||||
amo50Percent: amo50Percent,
|
||||
coefficientOfVariation: coefficientOfVariation,
|
||||
validIntervalCount: values.count,
|
||||
rejectedIntervalCount: rejectedCount,
|
||||
rejectedIntervalRatio: rejectedRatio,
|
||||
reliable: reliable
|
||||
)
|
||||
return PRVCalculationResult(metrics: metrics, intervals: intervals)
|
||||
}
|
||||
|
||||
public static func calculate(
|
||||
intervalsMilliseconds: [Double],
|
||||
configuration: PPGProcessingConfiguration
|
||||
) throws -> PRVMetrics {
|
||||
var elapsed = 0.0
|
||||
let intervals = intervalsMilliseconds.enumerated().map { index, duration in
|
||||
let start = elapsed
|
||||
elapsed += duration / 1_000
|
||||
return PulseInterval(
|
||||
sourceIndex: index,
|
||||
startSeconds: start,
|
||||
endSeconds: elapsed,
|
||||
durationMilliseconds: duration
|
||||
)
|
||||
}
|
||||
return try calculate(intervals: intervals, configuration: configuration).metrics
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user