import Foundation public enum PPGSignalError: Error, Equatable, Sendable { case invalidConfiguration(String) case insufficientSamples(required: Int, actual: Int) case insufficientDuration(requiredSeconds: Double, actualSeconds: Double) case invalidTimestamps case noUsableChannel } /// Compact numeric output expected from the camera frame extractor. /// No image data is retained by the signal-processing core. public struct PPGFrameSample: Codable, Equatable, Sendable { public var timestampSeconds: Double public var red: Double public var green: Double public var blue: Double public var luminance: Double public var contactConfidence: Double public var saturationRatio: Double public var motionArtifactScore: Double public init( timestampSeconds: Double, red: Double, green: Double, blue: Double? = nil, luminance: Double, contactConfidence: Double = 1, saturationRatio: Double = 0, motionArtifactScore: Double = 0 ) { self.timestampSeconds = timestampSeconds self.red = red self.green = green let reconstructedBlue = (luminance - 0.2126 * red - 0.7152 * green) / 0.0722 self.blue = blue ?? min(max(reconstructedBlue.isFinite ? reconstructedBlue : luminance, 0), 255) self.luminance = luminance self.contactConfidence = Self.clamp01(contactConfidence) self.saturationRatio = Self.clamp01(saturationRatio) self.motionArtifactScore = Self.clamp01(motionArtifactScore) } public var hasFiniteSignalValues: Bool { timestampSeconds.isFinite && red.isFinite && green.isFinite && blue.isFinite && luminance.isFinite } private static func clamp01(_ value: Double) -> Double { guard value.isFinite else { return 0 } return min(max(value, 0), 1) } } public enum PPGSignalChannel: String, CaseIterable, Codable, Equatable, Sendable { case red case green case blue case luminance case greenRedRatio case blueRedRatio case normalizedBlend } /// Publication gates for the three product decisions made from one camera capture. /// /// These thresholds deliberately do not mean the same thing: /// - mean BPM can be published from a technically usable periodic signal; /// - beat-to-beat PRV needs substantially better timing quality; /// - the potential-activation index is gated again by the product layer and a /// personalized baseline. It must never be presented as a medical stress measure. /// /// Keeping the values named here prevents a permissive live-preview threshold from /// accidentally becoming the PRV or product-index threshold. public enum PPGPublicationThresholds { /// Minimum final technical score for a definitive mean BPM. public static let bpmQualityScore = 0.45 /// Minimum temporal/frequency estimator confidence for a definitive mean BPM. public static let bpmConfidence = 0.40 /// Minimum final technical score for beat-to-beat PRV to be marked reliable. public static let prvQualityScore = 0.80 /// Minimum estimator confidence for beat-to-beat PRV to be marked reliable. public static let prvBpmConfidence = 0.70 /// Product-layer signal gate used before calculating the potential index. public static let potentialIndexQualityScore = 0.65 /// Product-layer minimum number of accepted pulse intervals. public static let potentialIndexMinimumIntervals = 20 } public struct PPGProcessingConfiguration: Codable, Equatable, Sendable { public var targetSampleRateHz: Double public var minimumFrequencyHz: Double public var maximumFrequencyHz: Double public var minimumAnalysisDurationSeconds: Double public var maximumInterpolationGapSeconds: Double public var longGapLimitSeconds: Double public var minimumContactConfidence: Double public var maximumSaturationRatio: Double public var maximumMotionArtifactScore: Double public var minimumPeakProminence: Double public var minimumPeakAmplitude: Double public var refractoryPeriodSeconds: Double public var maximumMethodDisagreementBPM: Double public var minimumQualityScore: Double public var minimumIntervalsForPRV: Int public var maximumRejectedIntervalRatioForPRV: Double public var filterTapCount: Int? public init( targetSampleRateHz: Double = 30, minimumFrequencyHz: Double = 0.7, maximumFrequencyHz: Double = 3.5, minimumAnalysisDurationSeconds: Double = 45, maximumInterpolationGapSeconds: Double = 0.20, longGapLimitSeconds: Double = 0.80, minimumContactConfidence: Double = 0.55, maximumSaturationRatio: Double = 0.70, maximumMotionArtifactScore: Double = 0.65, minimumPeakProminence: Double = 0.35, minimumPeakAmplitude: Double = 0.10, refractoryPeriodSeconds: Double = 0.25, maximumMethodDisagreementBPM: Double = 8, minimumQualityScore: Double = PPGPublicationThresholds.potentialIndexQualityScore, minimumIntervalsForPRV: Int = 20, maximumRejectedIntervalRatioForPRV: Double = 0.20, filterTapCount: Int? = nil ) { self.targetSampleRateHz = targetSampleRateHz self.minimumFrequencyHz = minimumFrequencyHz self.maximumFrequencyHz = maximumFrequencyHz self.minimumAnalysisDurationSeconds = minimumAnalysisDurationSeconds self.maximumInterpolationGapSeconds = maximumInterpolationGapSeconds self.longGapLimitSeconds = longGapLimitSeconds self.minimumContactConfidence = Self.clamp01(minimumContactConfidence) self.maximumSaturationRatio = Self.clamp01(maximumSaturationRatio) self.maximumMotionArtifactScore = Self.clamp01(maximumMotionArtifactScore) self.minimumPeakProminence = max(minimumPeakProminence, 0) self.minimumPeakAmplitude = max(minimumPeakAmplitude, 0) self.refractoryPeriodSeconds = max(refractoryPeriodSeconds, 0) self.maximumMethodDisagreementBPM = max(maximumMethodDisagreementBPM, 0) self.minimumQualityScore = Self.clamp01(minimumQualityScore) self.minimumIntervalsForPRV = max(minimumIntervalsForPRV, 2) self.maximumRejectedIntervalRatioForPRV = Self.clamp01(maximumRejectedIntervalRatioForPRV) self.filterTapCount = filterTapCount } public static let production = PPGProcessingConfiguration() public func validated() throws -> Self { guard targetSampleRateHz.isFinite, targetSampleRateHz >= 10 else { throw PPGSignalError.invalidConfiguration("targetSampleRateHz must be at least 10 Hz") } guard minimumFrequencyHz.isFinite, maximumFrequencyHz.isFinite, minimumFrequencyHz > 0, maximumFrequencyHz > minimumFrequencyHz, maximumFrequencyHz < targetSampleRateHz / 2 else { throw PPGSignalError.invalidConfiguration("frequency band must be inside (0, Nyquist)") } guard minimumAnalysisDurationSeconds.isFinite, minimumAnalysisDurationSeconds > 0 else { throw PPGSignalError.invalidConfiguration("minimumAnalysisDurationSeconds must be positive") } guard maximumInterpolationGapSeconds.isFinite, maximumInterpolationGapSeconds > 0, longGapLimitSeconds.isFinite, longGapLimitSeconds >= maximumInterpolationGapSeconds else { throw PPGSignalError.invalidConfiguration("gap limits are inconsistent") } if let filterTapCount, filterTapCount < 5 { throw PPGSignalError.invalidConfiguration("filterTapCount must be at least 5") } return self } private static func clamp01(_ value: Double) -> Double { guard value.isFinite else { return 0 } return min(max(value, 0), 1) } } public struct TimestampedValue: Equatable, Sendable { public var timestampSeconds: Double public var value: Double public init(timestampSeconds: Double, value: Double) { self.timestampSeconds = timestampSeconds self.value = value } } public struct TimebaseDiagnostics: Codable, Equatable, Sendable { public var inputSampleCount: Int public var usableInputSampleCount: Int public var discardedSampleCount: Int public var duplicateTimestampCount: Int public var outOfOrderTimestampCount: Int public var durationSeconds: Double public var usableDurationSeconds: Double public var estimatedSampleRateHz: Double public var frameIntervalMADSeconds: Double public var droppedFrameRatio: Double public var maximumGapSeconds: Double public var longGapDurationSeconds: Double public init( inputSampleCount: Int, usableInputSampleCount: Int, discardedSampleCount: Int, duplicateTimestampCount: Int, outOfOrderTimestampCount: Int, durationSeconds: Double, usableDurationSeconds: Double, estimatedSampleRateHz: Double, frameIntervalMADSeconds: Double, droppedFrameRatio: Double, maximumGapSeconds: Double, longGapDurationSeconds: Double ) { self.inputSampleCount = inputSampleCount self.usableInputSampleCount = usableInputSampleCount self.discardedSampleCount = discardedSampleCount self.duplicateTimestampCount = duplicateTimestampCount self.outOfOrderTimestampCount = outOfOrderTimestampCount self.durationSeconds = max(durationSeconds, 0) self.usableDurationSeconds = max(usableDurationSeconds, 0) self.estimatedSampleRateHz = max(estimatedSampleRateHz, 0) self.frameIntervalMADSeconds = max(frameIntervalMADSeconds, 0) self.droppedFrameRatio = min(max(droppedFrameRatio, 0), 1) self.maximumGapSeconds = max(maximumGapSeconds, 0) self.longGapDurationSeconds = max(longGapDurationSeconds, 0) } } public struct ResampledSignal: Equatable, Sendable { public var timestampsSeconds: [Double] public var values: [Double] public var validityMask: [Bool] public var diagnostics: TimebaseDiagnostics public init( timestampsSeconds: [Double], values: [Double], validityMask: [Bool], diagnostics: TimebaseDiagnostics ) { precondition(timestampsSeconds.count == values.count) precondition(values.count == validityMask.count) self.timestampsSeconds = timestampsSeconds self.values = values self.validityMask = validityMask self.diagnostics = diagnostics } } public struct DetectedPeak: Codable, Equatable, Sendable { public var sampleIndex: Int public var timestampSeconds: Double public var amplitude: Double public var prominence: Double public init(sampleIndex: Int, timestampSeconds: Double, amplitude: Double, prominence: Double) { self.sampleIndex = sampleIndex self.timestampSeconds = timestampSeconds self.amplitude = amplitude self.prominence = max(prominence, 0) } } public enum PulseIntervalRejectionReason: String, Codable, Equatable, Sendable { case outsidePhysiologicalRange case localOutlier case invalidSignalRegion case lowMorphologyQuality } public struct PulseInterval: Codable, Equatable, Sendable { public var sourceIndex: Int public var startSeconds: Double public var endSeconds: Double public var durationMilliseconds: Double public var rejectionReason: PulseIntervalRejectionReason? /// Per-beat technical quality. This is not a medical confidence score. public var qualityScore: Double public init( sourceIndex: Int, startSeconds: Double, endSeconds: Double, durationMilliseconds: Double, rejectionReason: PulseIntervalRejectionReason? = nil, qualityScore: Double = 1 ) { self.sourceIndex = sourceIndex self.startSeconds = startSeconds self.endSeconds = endSeconds self.durationMilliseconds = durationMilliseconds self.rejectionReason = rejectionReason self.qualityScore = min(max(qualityScore.isFinite ? qualityScore : 0, 0), 1) } public var isAccepted: Bool { rejectionReason == nil } } public struct PeakDetectionResult: Equatable, Sendable { public var peaks: [DetectedPeak] public var intervals: [PulseInterval] public var selectedPolarity: Int public var confidence: Double public init( peaks: [DetectedPeak], intervals: [PulseInterval], selectedPolarity: Int, confidence: Double ) { self.peaks = peaks self.intervals = intervals self.selectedPolarity = selectedPolarity >= 0 ? 1 : -1 self.confidence = Self.clamp01(confidence) } public var acceptedIntervals: [PulseInterval] { intervals.filter(\.isAccepted) } public var rejectedIntervalCount: Int { intervals.count - acceptedIntervals.count } private static func clamp01(_ value: Double) -> Double { guard value.isFinite else { return 0 } return min(max(value, 0), 1) } } public struct BPMEstimate: Codable, Equatable, Sendable { /// Final BPM. It is nil when the independent temporal and frequency estimates disagree. public var bpm: Double? public var temporalBpm: Double? public var frequencyBpm: Double? public var methodAgreementBpm: Double? public var validPeakCount: Int public var rejectedPeakCount: Int public var confidence: Double public var selectedChannel: PPGSignalChannel public var selectedPolarity: Int public init( bpm: Double?, temporalBpm: Double?, frequencyBpm: Double?, methodAgreementBpm: Double?, validPeakCount: Int, rejectedPeakCount: Int, confidence: Double, selectedChannel: PPGSignalChannel, selectedPolarity: Int ) { self.bpm = bpm self.temporalBpm = temporalBpm self.frequencyBpm = frequencyBpm self.methodAgreementBpm = methodAgreementBpm self.validPeakCount = max(validPeakCount, 0) self.rejectedPeakCount = max(rejectedPeakCount, 0) self.confidence = Self.clamp01(confidence) self.selectedChannel = selectedChannel self.selectedPolarity = selectedPolarity >= 0 ? 1 : -1 } private static func clamp01(_ value: Double) -> Double { guard value.isFinite else { return 0 } return min(max(value, 0), 1) } } public struct PRVMetrics: Codable, Equatable, Sendable { public var estimatedRmssdMs: Double? public var estimatedSdnnMs: Double? public var meanIpiMs: Double? public var medianIpiMs: Double? public var pnn50Percent: Double? public var rangeIpiMs: Double? public var modeIpiMs: Double? public var amo50Percent: Double? public var coefficientOfVariation: Double? public var validIntervalCount: Int public var rejectedIntervalCount: Int public var rejectedIntervalRatio: Double public var reliable: Bool public init( estimatedRmssdMs: Double?, estimatedSdnnMs: Double?, meanIpiMs: Double?, medianIpiMs: Double?, pnn50Percent: Double?, rangeIpiMs: Double?, modeIpiMs: Double?, amo50Percent: Double?, coefficientOfVariation: Double?, validIntervalCount: Int, rejectedIntervalCount: Int, rejectedIntervalRatio: Double, reliable: Bool ) { self.estimatedRmssdMs = estimatedRmssdMs self.estimatedSdnnMs = estimatedSdnnMs self.meanIpiMs = meanIpiMs self.medianIpiMs = medianIpiMs self.pnn50Percent = pnn50Percent self.rangeIpiMs = rangeIpiMs self.modeIpiMs = modeIpiMs self.amo50Percent = amo50Percent self.coefficientOfVariation = coefficientOfVariation self.validIntervalCount = max(validIntervalCount, 0) self.rejectedIntervalCount = max(rejectedIntervalCount, 0) self.rejectedIntervalRatio = Self.clamp01(rejectedIntervalRatio) self.reliable = reliable } private static func clamp01(_ value: Double) -> Double { guard value.isFinite else { return 1 } return min(max(value, 0), 1) } } public enum SignalQualityLevel: String, Codable, Equatable, Sendable { case good case medium case poor case invalid } public enum SignalQualityIssue: String, Codable, Equatable, Hashable, Sendable { case insufficientDuration case invalidTimestamps case longInterruption case tooManyDroppedFrames case irregularTimestamps case poorFingerContact case excessiveSaturation case motionArtifacts case lowPulsatileAmplitude case lowSignalToNoise case tooFewPeaks case inconsistentBPMMethods case tooManyRejectedIntervals case unstableChannel case nonFiniteInput } public struct SignalQualityResult: Codable, Equatable, Sendable { public var score: Double public var level: SignalQualityLevel public var usableDurationSeconds: Double public var droppedFrameRatio: Double public var saturationRatio: Double public var rejectedIntervalRatio: Double /// Signal-to-residual ratio in decibels around the selected pulse frequency. public var signalToNoiseRatio: Double? public var motionArtifactRatio: Double public var methodAgreementScore: Double public var issues: [SignalQualityIssue] public init( score: Double, level: SignalQualityLevel, usableDurationSeconds: Double, droppedFrameRatio: Double, saturationRatio: Double, rejectedIntervalRatio: Double, signalToNoiseRatio: Double?, motionArtifactRatio: Double, methodAgreementScore: Double, issues: [SignalQualityIssue] ) { self.score = Self.clamp01(score) self.level = level self.usableDurationSeconds = max(usableDurationSeconds, 0) self.droppedFrameRatio = Self.clamp01(droppedFrameRatio) self.saturationRatio = Self.clamp01(saturationRatio) self.rejectedIntervalRatio = Self.clamp01(rejectedIntervalRatio) self.signalToNoiseRatio = signalToNoiseRatio?.isFinite == true ? signalToNoiseRatio : nil self.motionArtifactRatio = Self.clamp01(motionArtifactRatio) self.methodAgreementScore = Self.clamp01(methodAgreementScore) self.issues = Array(Set(issues)).sorted { $0.rawValue < $1.rawValue } } private static func clamp01(_ value: Double) -> Double { guard value.isFinite else { return 0 } return min(max(value, 0), 1) } } public enum PPGAnalysisStatus: String, Codable, Equatable, Sendable { case valid case invalid } public struct PPGAnalysisResult: Codable, Equatable, Sendable { public var status: PPGAnalysisStatus public var bpm: BPMEstimate public var prv: PRVMetrics public var quality: SignalQualityResult public var peaks: [DetectedPeak] public var intervals: [PulseInterval] public var timebase: TimebaseDiagnostics public init( status: PPGAnalysisStatus, bpm: BPMEstimate, prv: PRVMetrics, quality: SignalQualityResult, peaks: [DetectedPeak], intervals: [PulseInterval], timebase: TimebaseDiagnostics ) { self.status = status self.bpm = bpm self.prv = prv self.quality = quality self.peaks = peaks self.intervals = intervals self.timebase = timebase } }