import Foundation public struct PPGSignalProcessor: Sendable { public let configuration: PPGProcessingConfiguration public init(configuration: PPGProcessingConfiguration = .production) throws { self.configuration = try configuration.validated() } public func analyze(samples inputSamples: [PPGFrameSample]) throws -> PPGAnalysisResult { let finiteSamples = inputSamples.filter(\.hasFiniteSignalValues) let discardedInputCount = inputSamples.count - finiteSamples.count guard finiteSamples.count >= 3 else { throw PPGSignalError.insufficientSamples(required: 3, actual: finiteSamples.count) } let sortedTimestamps = finiteSamples.map(\.timestampSeconds).sorted() guard let firstTimestamp = sortedTimestamps.first, let lastTimestamp = sortedTimestamps.last, lastTimestamp > firstTimestamp else { throw PPGSignalError.invalidTimestamps } let duration = lastTimestamp - firstTimestamp guard duration >= configuration.minimumAnalysisDurationSeconds else { throw PPGSignalError.insufficientDuration( requiredSeconds: configuration.minimumAnalysisDurationSeconds, actualSeconds: duration ) } let red = try resample(finiteSamples, value: \.red) let green = try resample(finiteSamples, value: \.green) let blue = try resample(finiteSamples, value: \.blue) let luminance = try resample(finiteSamples, value: \.luminance) let contact = try resample(finiteSamples, value: \.contactConfidence) let saturation = try resample(finiteSamples, value: \.saturationRatio) let motion = try resample(finiteSamples, value: \.motionArtifactScore) let greenRedRatio = try PPGSignalResampler.resample( finiteSamples.map { let total = max($0.red + $0.green + $0.blue, 1) return TimestampedValue( timestampSeconds: $0.timestampSeconds, value: $0.green / total ) }, targetSampleRateHz: configuration.targetSampleRateHz, maximumInterpolationGapSeconds: configuration.maximumInterpolationGapSeconds ) let blueRedRatio = try PPGSignalResampler.resample( finiteSamples.map { let total = max($0.red + $0.green + $0.blue, 1) return TimestampedValue( timestampSeconds: $0.timestampSeconds, value: $0.blue / total ) }, targetSampleRateHz: configuration.targetSampleRateHz, maximumInterpolationGapSeconds: configuration.maximumInterpolationGapSeconds ) guard red.values.count == green.values.count, red.values.count == luminance.values.count, red.values.count == contact.values.count, red.values.count == saturation.values.count, red.values.count == motion.values.count else { throw PPGSignalError.invalidTimestamps } let blendValues = normalizedBlend( red: red.values, green: green.values, blue: blue.values, luminance: luminance.values ) let blendMask = zip(red.validityMask, zip(green.validityMask, luminance.validityMask)).map { $0.0 && $0.1.0 && $0.1.1 } let blend = ResampledSignal( timestampsSeconds: red.timestampsSeconds, values: blendValues, validityMask: blendMask, diagnostics: red.diagnostics ) let filter = try PPGBandPassFilter( sampleRateHz: configuration.targetSampleRateHz, lowCutoffHz: configuration.minimumFrequencyHz, highCutoffHz: configuration.maximumFrequencyHz, tapCount: configuration.filterTapCount ) let commonMetricMask = zip(contact.validityMask, zip(saturation.validityMask, motion.validityMask)) .map { $0.0 && $0.1.0 && $0.1.1 } // The coaching contact score must never decide whether a frame contains a // pulse. Camera colour varies too much between front sensors and exposure // modes. Only timestamp continuity and extreme optical corruption create // invalid regions; periodicity and beat consistency decide quality later. let physiologicalMask = contact.values.indices.map { index in commonMetricMask[index] && saturation.values[index] <= configuration.maximumSaturationRatio && motion.values[index] <= configuration.maximumMotionArtifactScore } let guardedPhysiologicalMask = PPGSignalPreprocessor.dilateInvalidRegions( physiologicalMask, radius: max(Int((configuration.targetSampleRateHz * 0.20).rounded()), 1) ) let sourceCandidates: [(PPGSignalChannel, ResampledSignal)] = [ (.red, red), (.green, green), (.blue, blue), (.luminance, luminance), (.greenRedRatio, greenRedRatio), (.blueRedRatio, blueRedRatio), (.normalizedBlend, blend), ] let candidates = try sourceCandidates.map { channel, resampled in try analyzeChannel( channel: channel, resampled: resampled, guardedPhysiologicalMask: guardedPhysiologicalMask, filter: filter ) } guard var selected = candidates.max(by: channelCandidateIsWorse) else { throw PPGSignalError.noUsableChannel } selected.timebase.inputSampleCount = inputSamples.count selected.timebase.discardedSampleCount += discardedInputCount let prvCalculation = try PPGPRVCalculator.calculate( intervals: selected.peakDetection.intervals, configuration: configuration ) let validCoverage = Double(selected.validityMask.filter { $0 }.count) / Double(max(selected.validityMask.count, 1)) var qualityTimebase = selected.timebase qualityTimebase.usableDurationSeconds = min( qualityTimebase.usableDurationSeconds, qualityTimebase.durationSeconds * validCoverage ) let quality = PPGSignalQualityEvaluator.evaluate( SignalQualityInput( timebase: qualityTimebase, contactConfidence: contact.values, saturationRatio: saturation.values, motionArtifactScore: motion.values, filteredSignal: selected.filteredSignal, validityMask: selected.validityMask, bpmEstimate: selected.bpm, intervals: prvCalculation.intervals, signalToNoiseRatioDB: selected.snrDB, channelStabilityScore: selected.selectionScore ), configuration: configuration ) // A camera PPG can provide a useful mean heart rate before it provides // beat-to-beat timing good enough for PRV. Publish that conservative BPM // when the evaluator did not mark the signal structurally invalid; keep // PRV reliability gated below by the stricter quality/confidence checks. // This mirrors how practical camera HR apps remain useful on borderline // captures without turning them into HRV measurements. let isValid = quality.level != .invalid && quality.score >= PPGPublicationThresholds.bpmQualityScore && selected.bpm.confidence >= PPGPublicationThresholds.bpmConfidence && selected.bpm.bpm != nil var publishedBPM = selected.bpm var publishedPRV = prvCalculation.metrics if !isValid { // Keep independent estimates for diagnostics, but do not publish a definitive BPM. publishedBPM.bpm = nil publishedPRV.reliable = false } else if quality.score < PPGPublicationThresholds.prvQualityScore || selected.bpm.confidence < PPGPublicationThresholds.prvBpmConfidence { // Beat-to-beat variability requires a stricter signal than mean pulse rate. publishedPRV.reliable = false } return PPGAnalysisResult( status: isValid ? .valid : .invalid, bpm: publishedBPM, prv: publishedPRV, quality: quality, peaks: selected.peakDetection.peaks, intervals: prvCalculation.intervals, timebase: selected.timebase ) } private struct ChannelCandidate { var channel: PPGSignalChannel var filteredSignal: [Double] var validityMask: [Bool] var timebase: TimebaseDiagnostics var peakDetection: PeakDetectionResult var bpm: BPMEstimate var snrDB: Double? var selectionScore: Double } private func analyzeChannel( channel: PPGSignalChannel, resampled: ResampledSignal, guardedPhysiologicalMask: [Bool], filter: PPGBandPassFilter ) throws -> ChannelCandidate { let prepared = PPGSignalPreprocessor.detrendAndNormalize( resampled.values, sampleRateHz: configuration.targetSampleRateHz ) let filtered = filter.apply(to: prepared) let gapGuardedMask = PPGSignalPreprocessor.dilateInvalidRegions( resampled.validityMask, radius: filter.invalidRegionGuardRadius ) var validityMask = zip(gapGuardedMask, guardedPhysiologicalMask).map { $0.0 && $0.1 } let edgeGuard = min(filter.invalidRegionGuardRadius, validityMask.count / 4) if edgeGuard > 0 { for index in 0.. ) throws -> ResampledSignal { try PPGSignalResampler.resample( samples.map { TimestampedValue(timestampSeconds: $0.timestampSeconds, value: $0[keyPath: keyPath]) }, targetSampleRateHz: configuration.targetSampleRateHz, maximumInterpolationGapSeconds: configuration.maximumInterpolationGapSeconds ) } private func normalizedBlend( red: [Double], green: [Double], blue: [Double], luminance: [Double] ) -> [Double] { let standardizedRed = robustStandardize(red) let standardizedGreen = robustStandardize(green) let standardizedBlue = robustStandardize(blue) let standardizedLuminance = robustStandardize(luminance) return standardizedRed.indices.map { index in // A compact chrominance projection suppresses common illumination and // automatic-exposure changes while preserving blood-volume modulation. 0.70 * standardizedGreen[index] - 0.40 * standardizedRed[index] - 0.30 * standardizedBlue[index] + 0.10 * standardizedLuminance[index] } } private func robustStandardize(_ values: [Double]) -> [Double] { let center = SignalStatistics.median(values) let scale = max( 1.4826 * SignalStatistics.mad(values, around: center), SignalStatistics.standardDeviation(values) * 0.20, 1e-9 ) return values.map { min(max(($0 - center) / scale, -12), 12) } } private func channelCandidateIsWorse(_ lhs: ChannelCandidate, _ rhs: ChannelCandidate) -> Bool { if abs(lhs.selectionScore - rhs.selectionScore) > 1e-12 { return lhs.selectionScore < rhs.selectionScore } let lhsIndex = PPGSignalChannel.allCases.firstIndex(of: lhs.channel) ?? 0 let rhsIndex = PPGSignalChannel.allCases.firstIndex(of: rhs.channel) ?? 0 // Prefer the earlier, simpler channel when scores are exactly tied. return lhsIndex > rhsIndex } }