177 lines
7.4 KiB
Swift
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)
|
|
}
|
|
}
|