524 lines
18 KiB
Swift
524 lines
18 KiB
Swift
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
|
|
}
|
|
}
|