feat: implement Haptika PPG and Watch V1
This commit is contained in:
@@ -0,0 +1,176 @@
|
||||
import Foundation
|
||||
|
||||
public struct SignalQualityInput: Equatable, Sendable {
|
||||
public var timebase: TimebaseDiagnostics
|
||||
public var contactConfidence: [Double]
|
||||
public var saturationRatio: [Double]
|
||||
public var motionArtifactScore: [Double]
|
||||
public var filteredSignal: [Double]
|
||||
public var validityMask: [Bool]
|
||||
public var bpmEstimate: BPMEstimate
|
||||
public var intervals: [PulseInterval]
|
||||
public var signalToNoiseRatioDB: Double?
|
||||
public var channelStabilityScore: Double
|
||||
|
||||
public init(
|
||||
timebase: TimebaseDiagnostics,
|
||||
contactConfidence: [Double],
|
||||
saturationRatio: [Double],
|
||||
motionArtifactScore: [Double],
|
||||
filteredSignal: [Double],
|
||||
validityMask: [Bool],
|
||||
bpmEstimate: BPMEstimate,
|
||||
intervals: [PulseInterval],
|
||||
signalToNoiseRatioDB: Double?,
|
||||
channelStabilityScore: Double
|
||||
) {
|
||||
self.timebase = timebase
|
||||
self.contactConfidence = contactConfidence
|
||||
self.saturationRatio = saturationRatio
|
||||
self.motionArtifactScore = motionArtifactScore
|
||||
self.filteredSignal = filteredSignal
|
||||
self.validityMask = validityMask
|
||||
self.bpmEstimate = bpmEstimate
|
||||
self.intervals = intervals
|
||||
self.signalToNoiseRatioDB = signalToNoiseRatioDB
|
||||
self.channelStabilityScore = SignalStatistics.clamp01(channelStabilityScore)
|
||||
}
|
||||
}
|
||||
|
||||
public enum PPGSignalQualityEvaluator {
|
||||
public static func evaluate(
|
||||
_ input: SignalQualityInput,
|
||||
configuration: PPGProcessingConfiguration
|
||||
) -> SignalQualityResult {
|
||||
let totalCount = max(input.validityMask.count, 1)
|
||||
let validCoverage = Double(input.validityMask.filter { $0 }.count) / Double(totalCount)
|
||||
let contactMean = boundedMean(input.contactConfidence)
|
||||
let saturationMean = boundedMean(input.saturationRatio)
|
||||
let motionMean = boundedMean(input.motionArtifactScore)
|
||||
let motionFrameRatio = input.motionArtifactScore.isEmpty
|
||||
? 0
|
||||
: Double(input.motionArtifactScore.filter { $0 >= 0.50 }.count)
|
||||
/ Double(input.motionArtifactScore.count)
|
||||
let motionArtifactRatio = max(motionMean, motionFrameRatio)
|
||||
|
||||
let validSignal = input.filteredSignal.indices.compactMap { index -> Double? in
|
||||
guard index < input.validityMask.count,
|
||||
input.validityMask[index],
|
||||
input.filteredSignal[index].isFinite
|
||||
else { return nil }
|
||||
return input.filteredSignal[index]
|
||||
}
|
||||
let pulsatileAmplitude = validSignal.isEmpty
|
||||
? 0
|
||||
: SignalStatistics.percentile(validSignal, 0.95)
|
||||
- SignalStatistics.percentile(validSignal, 0.05)
|
||||
let amplitudeScore = SignalStatistics.clamp01(pulsatileAmplitude / 2.0)
|
||||
let snrScore = input.signalToNoiseRatioDB.map {
|
||||
SignalStatistics.clamp01(($0 + 3) / 15)
|
||||
} ?? 0
|
||||
|
||||
let sampleInterval = input.timebase.estimatedSampleRateHz > 0
|
||||
? 1 / input.timebase.estimatedSampleRateHz
|
||||
: 0
|
||||
let jitterRatio = sampleInterval > 0
|
||||
? input.timebase.frameIntervalMADSeconds / sampleInterval
|
||||
: 1
|
||||
let droppedFrameScore = SignalStatistics.clamp01(1 - input.timebase.droppedFrameRatio / 0.15)
|
||||
let jitterScore = SignalStatistics.clamp01(1 - jitterRatio / 0.15)
|
||||
let gapScore = input.timebase.maximumGapSeconds <= configuration.maximumInterpolationGapSeconds
|
||||
? 1.0
|
||||
: SignalStatistics.clamp01(
|
||||
1 - input.timebase.longGapDurationSeconds / max(input.timebase.durationSeconds, 1e-9)
|
||||
)
|
||||
let timebaseScore = 0.45 * droppedFrameScore + 0.35 * jitterScore + 0.20 * gapScore
|
||||
let durationScore = SignalStatistics.clamp01(
|
||||
input.timebase.usableDurationSeconds / configuration.minimumAnalysisDurationSeconds
|
||||
)
|
||||
let contactScore = SignalStatistics.clamp01(contactMean)
|
||||
let saturationScore = SignalStatistics.clamp01(1 - saturationMean / 0.70)
|
||||
let motionScore = SignalStatistics.clamp01(1 - motionArtifactRatio / 0.75)
|
||||
let peakScore = input.bpmEstimate.confidence
|
||||
let agreementScore = input.bpmEstimate.methodAgreementBpm.map {
|
||||
SignalStatistics.clamp01(1 - $0 / max(configuration.maximumMethodDisagreementBPM, 1e-9))
|
||||
} ?? 0
|
||||
let rejectedIntervalRatio = input.intervals.isEmpty
|
||||
? 1
|
||||
: Double(input.intervals.filter { !$0.isAccepted }.count) / Double(input.intervals.count)
|
||||
let intervalScore = SignalStatistics.clamp01(1 - rejectedIntervalRatio / 0.35)
|
||||
|
||||
let score = 0.03 * contactScore
|
||||
+ 0.05 * saturationScore
|
||||
+ 0.06 * motionScore
|
||||
+ 0.14 * timebaseScore
|
||||
+ 0.12 * durationScore
|
||||
+ 0.08 * amplitudeScore
|
||||
+ 0.20 * snrScore
|
||||
+ 0.14 * peakScore
|
||||
+ 0.09 * agreementScore
|
||||
+ 0.05 * intervalScore
|
||||
+ 0.04 * input.channelStabilityScore
|
||||
|
||||
var issues: [SignalQualityIssue] = []
|
||||
if input.timebase.usableDurationSeconds < configuration.minimumAnalysisDurationSeconds {
|
||||
issues.append(.insufficientDuration)
|
||||
}
|
||||
if input.timebase.outOfOrderTimestampCount > 0 || input.timebase.duplicateTimestampCount > 0 {
|
||||
issues.append(.invalidTimestamps)
|
||||
}
|
||||
if input.timebase.maximumGapSeconds > configuration.longGapLimitSeconds {
|
||||
issues.append(.longInterruption)
|
||||
}
|
||||
if input.timebase.droppedFrameRatio > 0.12 { issues.append(.tooManyDroppedFrames) }
|
||||
if jitterRatio > 0.12 { issues.append(.irregularTimestamps) }
|
||||
if contactMean < 0.35 || validCoverage < 0.75 { issues.append(.poorFingerContact) }
|
||||
if saturationMean > 0.60 { issues.append(.excessiveSaturation) }
|
||||
if motionArtifactRatio > 0.55 { issues.append(.motionArtifacts) }
|
||||
if pulsatileAmplitude < 0.75 { issues.append(.lowPulsatileAmplitude) }
|
||||
if (input.signalToNoiseRatioDB ?? -.infinity) < 3 { issues.append(.lowSignalToNoise) }
|
||||
if input.bpmEstimate.validPeakCount < 8 { issues.append(.tooFewPeaks) }
|
||||
if input.bpmEstimate.bpm == nil { issues.append(.inconsistentBPMMethods) }
|
||||
if rejectedIntervalRatio > 0.25 { issues.append(.tooManyRejectedIntervals) }
|
||||
if input.channelStabilityScore < 0.45 { issues.append(.unstableChannel) }
|
||||
if input.timebase.discardedSampleCount > 0 { issues.append(.nonFiniteInput) }
|
||||
|
||||
let hardInvalid = input.bpmEstimate.bpm == nil
|
||||
|| input.signalToNoiseRatioDB == nil
|
||||
|| (input.signalToNoiseRatioDB ?? -.infinity) < 0
|
||||
|| input.bpmEstimate.confidence < PPGPublicationThresholds.bpmConfidence
|
||||
|| input.timebase.maximumGapSeconds > configuration.longGapLimitSeconds
|
||||
|| validCoverage < 0.55
|
||||
|| saturationMean > 0.92
|
||||
|| motionArtifactRatio > 0.90
|
||||
|| input.timebase.usableDurationSeconds < configuration.minimumAnalysisDurationSeconds * 0.80
|
||||
|
||||
let clampedScore = SignalStatistics.clamp01(score)
|
||||
let level: SignalQualityLevel
|
||||
if hardInvalid || clampedScore < PPGPublicationThresholds.bpmQualityScore {
|
||||
level = .invalid
|
||||
} else if clampedScore < configuration.minimumQualityScore {
|
||||
level = .poor
|
||||
} else if clampedScore < PPGPublicationThresholds.prvQualityScore {
|
||||
level = .medium
|
||||
} else {
|
||||
level = .good
|
||||
}
|
||||
|
||||
return SignalQualityResult(
|
||||
score: clampedScore,
|
||||
level: level,
|
||||
usableDurationSeconds: input.timebase.usableDurationSeconds,
|
||||
droppedFrameRatio: input.timebase.droppedFrameRatio,
|
||||
saturationRatio: saturationMean,
|
||||
rejectedIntervalRatio: rejectedIntervalRatio,
|
||||
signalToNoiseRatio: input.signalToNoiseRatioDB,
|
||||
motionArtifactRatio: motionArtifactRatio,
|
||||
methodAgreementScore: agreementScore,
|
||||
issues: issues
|
||||
)
|
||||
}
|
||||
|
||||
private static func boundedMean(_ values: [Double]) -> Double {
|
||||
let finite = values.filter(\.isFinite).map { min(max($0, 0), 1) }
|
||||
return finite.isEmpty ? 0 : SignalStatistics.mean(finite)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user