Files
haptika/packages/react-native-haptika-ppg/ios/Signal/SignalQualityEvaluator.swift
T
2026-07-31 16:20:10 +02:00

177 lines
7.4 KiB
Swift

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)
}
}