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