feat: implement Haptika PPG and Watch V1

This commit is contained in:
Sacha
2026-07-31 16:19:42 +02:00
parent 6b7d4e0728
commit b9930b7378
249 changed files with 46475 additions and 122 deletions
@@ -0,0 +1,196 @@
import Foundation
public struct FrequencyBPMEstimate: Codable, Equatable, Sendable {
public var bpm: Double?
public var autocorrelation: Double
public var confidence: Double
public init(bpm: Double?, autocorrelation: Double, confidence: Double) {
self.bpm = bpm
self.autocorrelation = autocorrelation.isFinite ? autocorrelation : 0
self.confidence = SignalStatistics.clamp01(confidence)
}
}
public enum PPGBPMEstimator {
public static func estimate(
signal: [Double],
timestampsSeconds: [Double],
validityMask: [Bool],
peakDetection: PeakDetectionResult,
selectedChannel: PPGSignalChannel,
configuration: PPGProcessingConfiguration
) throws -> BPMEstimate {
let configuration = try configuration.validated()
guard signal.count == timestampsSeconds.count,
signal.count == validityMask.count,
signal.count >= 5
else {
throw PPGSignalError.insufficientSamples(required: 5, actual: signal.count)
}
let acceptedIntervals = peakDetection.acceptedIntervals.map(\.durationMilliseconds)
let temporalBPM: Double? = acceptedIntervals.count >= 4
? 60_000 / SignalStatistics.median(acceptedIntervals)
: nil
let frequency = frequencyEstimate(
signal: signal,
timestampsSeconds: timestampsSeconds,
validityMask: validityMask,
minimumFrequencyHz: configuration.minimumFrequencyHz,
maximumFrequencyHz: configuration.maximumFrequencyHz
)
// Autocorrelation can lock onto the second harmonic (2×) or a sub-harmonic
// (0.5×). Prefer the equivalent candidate closest to the clean beat-to-beat
// estimate before judging the two methods as inconsistent.
let adjustedFrequencyBPM = temporalBPM.flatMap { temporal -> Double? in
guard let raw = frequency.bpm else { return nil }
return [raw, raw / 2, raw * 2]
.filter {
$0 >= configuration.minimumFrequencyHz * 60
&& $0 <= configuration.maximumFrequencyHz * 60
}
.min(by: { abs($0 - temporal) < abs($1 - temporal) })
} ?? frequency.bpm
let agreement = temporalBPM.flatMap { temporal in
adjustedFrequencyBPM.map { abs(temporal - $0) }
}
let agreementScore = agreement.map {
SignalStatistics.clamp01(1 - $0 / max(configuration.maximumMethodDisagreementBPM, 1e-9))
} ?? 0
let finalBPM: Double?
if let temporalBPM,
let frequencyBPM = adjustedFrequencyBPM,
let agreement,
agreement <= configuration.maximumMethodDisagreementBPM {
finalBPM = 0.65 * temporalBPM + 0.35 * frequencyBPM
} else if let temporalBPM,
acceptedIntervals.count >= 8,
peakDetection.confidence >= 0.55 {
// A long, regular sequence of detected beats is sufficient for mean HR.
// Keep PRV subject to the stricter downstream interval-quality checks.
finalBPM = temporalBPM
} else if let frequencyBPM = adjustedFrequencyBPM,
frequency.confidence >= 0.55 {
finalBPM = frequencyBPM
} else {
finalBPM = nil
}
let countScore = min(Double(acceptedIntervals.count) / 10, 1)
let confidence = 0.40 * peakDetection.confidence
+ 0.25 * frequency.confidence
+ 0.25 * agreementScore
+ 0.10 * countScore
return BPMEstimate(
bpm: finalBPM,
temporalBpm: temporalBPM,
frequencyBpm: adjustedFrequencyBPM,
methodAgreementBpm: agreement,
validPeakCount: peakDetection.peaks.count,
rejectedPeakCount: peakDetection.rejectedIntervalCount,
confidence: confidence,
selectedChannel: selectedChannel,
selectedPolarity: peakDetection.selectedPolarity
)
}
public static func frequencyEstimate(
signal: [Double],
timestampsSeconds: [Double],
validityMask: [Bool],
minimumFrequencyHz: Double,
maximumFrequencyHz: Double
) -> FrequencyBPMEstimate {
guard signal.count == timestampsSeconds.count,
signal.count == validityMask.count,
signal.count >= 12,
minimumFrequencyHz > 0,
maximumFrequencyHz > minimumFrequencyHz
else {
return FrequencyBPMEstimate(bpm: nil, autocorrelation: 0, confidence: 0)
}
let intervals = zip(timestampsSeconds, timestampsSeconds.dropFirst())
.map { $1 - $0 }
.filter { $0.isFinite && $0 > 0 }
let sampleInterval = SignalStatistics.median(intervals)
guard sampleInterval > 0 else {
return FrequencyBPMEstimate(bpm: nil, autocorrelation: 0, confidence: 0)
}
let sampleRate = 1 / sampleInterval
let minimumLag = max(Int((sampleRate / maximumFrequencyHz).rounded(.down)), 2)
let maximumLag = min(Int((sampleRate / minimumFrequencyHz).rounded(.up)), signal.count / 2)
guard maximumLag > minimumLag else {
return FrequencyBPMEstimate(bpm: nil, autocorrelation: 0, confidence: 0)
}
let validValues = signal.indices.compactMap {
validityMask[$0] && signal[$0].isFinite ? signal[$0] : nil
}
let center = SignalStatistics.mean(validValues)
var correlations: [Int: Double] = [:]
for lag in minimumLag...maximumLag {
var cross = 0.0
var leftPower = 0.0
var rightPower = 0.0
var pairCount = 0
for index in 0..<(signal.count - lag) {
guard validityMask[index], validityMask[index + lag] else { continue }
let left = signal[index] - center
let right = signal[index + lag] - center
guard left.isFinite, right.isFinite else { continue }
cross += left * right
leftPower += left * left
rightPower += right * right
pairCount += 1
}
guard pairCount >= 10, leftPower > 1e-12, rightPower > 1e-12 else { continue }
correlations[lag] = cross / (leftPower * rightPower).squareRoot()
}
guard !correlations.isEmpty,
let strongest = correlations.max(by: { $0.value < $1.value }),
strongest.value > 0.10
else {
return FrequencyBPMEstimate(bpm: nil, autocorrelation: 0, confidence: 0)
}
let localPeaks = correlations.keys.sorted().filter { lag in
let value = correlations[lag] ?? -.infinity
let left = correlations[lag - 1] ?? -.infinity
let right = correlations[lag + 1] ?? -.infinity
return value >= left && value >= right
}
let strongThreshold = max(strongest.value * 0.92, 0.10)
let chosenLag = localPeaks.first(where: { (correlations[$0] ?? 0) >= strongThreshold })
?? strongest.key
let centerCorrelation = correlations[chosenLag] ?? strongest.value
let leftCorrelation = correlations[chosenLag - 1] ?? centerCorrelation
let rightCorrelation = correlations[chosenLag + 1] ?? centerCorrelation
let denominator = leftCorrelation - 2 * centerCorrelation + rightCorrelation
let fractionalOffset: Double
if abs(denominator) > 1e-12 {
fractionalOffset = min(
max(0.5 * (leftCorrelation - rightCorrelation) / denominator, -0.5),
0.5
)
} else {
fractionalOffset = 0
}
let refinedLag = Double(chosenLag) + fractionalOffset
guard refinedLag > 0 else {
return FrequencyBPMEstimate(bpm: nil, autocorrelation: centerCorrelation, confidence: 0)
}
let bpm = 60 * sampleRate / refinedLag
let confidence = SignalStatistics.clamp01((centerCorrelation - 0.10) / 0.75)
return FrequencyBPMEstimate(
bpm: bpm.isFinite ? bpm : nil,
autocorrelation: centerCorrelation,
confidence: confidence
)
}
}
@@ -0,0 +1,172 @@
import Foundation
public enum PPGSignalPreprocessor {
/// Robustly clips isolated outliers, subtracts a centered local baseline, and scales by MAD.
public static func detrendAndNormalize(
_ input: [Double],
sampleRateHz: Double,
baselineWindowSeconds: Double = 1.6
) -> [Double] {
guard !input.isEmpty, sampleRateHz.isFinite, sampleRateHz > 0 else { return [] }
let finite = sanitize(input)
let center = SignalStatistics.median(finite)
let rawMAD = SignalStatistics.mad(finite, around: center)
let fallbackScale = max(SignalStatistics.standardDeviation(finite), 1e-9)
let clipScale = max(1.4826 * rawMAD, fallbackScale * 0.25, 1e-9)
let clipped = finite.map { min(max($0, center - 8 * clipScale), center + 8 * clipScale) }
var window = max(Int((baselineWindowSeconds * sampleRateHz).rounded()), 3)
if window.isMultiple(of: 2) { window += 1 }
let baseline = centeredMovingAverage(clipped, window: window)
let detrended = zip(clipped, baseline).map { $0.0 - $0.1 }
let detrendedCenter = SignalStatistics.median(detrended)
let detrendedMAD = SignalStatistics.mad(detrended, around: detrendedCenter)
let detrendedSTD = SignalStatistics.standardDeviation(detrended)
let scale = max(1.4826 * detrendedMAD, detrendedSTD * 0.20, 1e-9)
return detrended.map { value in
min(max((value - detrendedCenter) / scale, -12), 12)
}
}
public static func centeredMovingAverage(_ input: [Double], window: Int) -> [Double] {
guard !input.isEmpty else { return [] }
let normalizedWindow = max(window, 1)
let half = normalizedWindow / 2
return input.indices.map { index in
var sum = 0.0
for offset in -half...half {
sum += input[SignalStatistics.reflectedIndex(index + offset, count: input.count)]
}
return sum / Double(2 * half + 1)
}
}
public static func dilateInvalidRegions(_ mask: [Bool], radius: Int) -> [Bool] {
guard radius > 0, !mask.isEmpty else { return mask }
var difference = Array(repeating: 0, count: mask.count + 1)
for index in mask.indices where !mask[index] {
let lower = max(0, index - radius)
let upper = min(mask.count - 1, index + radius)
difference[lower] += 1
difference[upper + 1] -= 1
}
var active = 0
return mask.indices.map { index in
active += difference[index]
return active == 0
}
}
private static func sanitize(_ input: [Double]) -> [Double] {
var lastFinite = input.first(where: \.isFinite) ?? 0
return input.map { value in
if value.isFinite { lastFinite = value }
return lastFinite
}
}
}
/// Symmetric, windowed-sinc FIR band-pass filter. Coefficients are regenerated from the actual
/// resampling frequency, so a 30 Hz and a 60 Hz capture use the same physiological pass band.
public struct PPGBandPassFilter: Equatable, Sendable {
public let sampleRateHz: Double
public let lowCutoffHz: Double
public let highCutoffHz: Double
public let coefficients: [Double]
public init(
sampleRateHz: Double,
lowCutoffHz: Double,
highCutoffHz: Double,
tapCount requestedTapCount: Int? = nil
) throws {
guard sampleRateHz.isFinite,
lowCutoffHz.isFinite,
highCutoffHz.isFinite,
sampleRateHz >= 10,
lowCutoffHz > 0,
highCutoffHz > lowCutoffHz,
highCutoffHz < sampleRateHz / 2
else {
throw PPGSignalError.invalidConfiguration("band-pass frequencies must lie inside Nyquist")
}
var tapCount = requestedTapCount
?? min(max(Int((sampleRateHz * 4).rounded()), 31), 301)
tapCount = max(tapCount, 5)
if tapCount.isMultiple(of: 2) { tapCount += 1 }
self.sampleRateHz = sampleRateHz
self.lowCutoffHz = lowCutoffHz
self.highCutoffHz = highCutoffHz
self.coefficients = Self.makeCoefficients(
sampleRateHz: sampleRateHz,
lowCutoffHz: lowCutoffHz,
highCutoffHz: highCutoffHz,
tapCount: tapCount
)
}
public var invalidRegionGuardRadius: Int { coefficients.count / 2 }
/// Applies centered convolution. The symmetric impulse response introduces no peak-time shift.
public func apply(to input: [Double]) -> [Double] {
guard !input.isEmpty else { return [] }
let clean = input.map { $0.isFinite ? $0 : 0 }
let half = coefficients.count / 2
var output = Array(repeating: 0.0, count: clean.count)
for outputIndex in clean.indices {
var accumulator = 0.0
for coefficientIndex in coefficients.indices {
let sourceIndex = SignalStatistics.reflectedIndex(
outputIndex + coefficientIndex - half,
count: clean.count
)
accumulator += coefficients[coefficientIndex] * clean[sourceIndex]
}
output[outputIndex] = accumulator.isFinite ? accumulator : 0
}
return output
}
private static func makeCoefficients(
sampleRateHz: Double,
lowCutoffHz: Double,
highCutoffHz: Double,
tapCount: Int
) -> [Double] {
let midpoint = tapCount / 2
let low = lowCutoffHz / sampleRateHz
let high = highCutoffHz / sampleRateHz
var taps = (0..<tapCount).map { index -> Double in
let offset = Double(index - midpoint)
let idealHigh = 2 * high * sinc(2 * high * offset)
let idealLow = 2 * low * sinc(2 * low * offset)
let hamming = 0.54 - 0.46 * cos(2 * Double.pi * Double(index) / Double(tapCount - 1))
return (idealHigh - idealLow) * hamming
}
let centerFrequency = (lowCutoffHz * highCutoffHz).squareRoot()
var real = 0.0
var imaginary = 0.0
for index in taps.indices {
let offset = Double(index - midpoint)
let angle = -2 * Double.pi * centerFrequency * offset / sampleRateHz
real += taps[index] * cos(angle)
imaginary += taps[index] * sin(angle)
}
let gain = (real * real + imaginary * imaginary).squareRoot()
if gain > 1e-12 {
taps = taps.map { $0 / gain }
}
return taps
}
private static func sinc(_ value: Double) -> Double {
guard abs(value) > 1e-12 else { return 1 }
return sin(Double.pi * value) / (Double.pi * value)
}
}
@@ -0,0 +1,523 @@
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
}
}
@@ -0,0 +1,60 @@
import Foundation
public enum PPGCoherencePolicy {
public static let spotMaximumDurationSeconds = 120.0
public static let coherenceMaximumDurationSeconds = 300.0
public static let coherenceBufferDurationSeconds = 75.0
public static func maximumDurationSeconds(isCoherence: Bool) -> Double {
isCoherence ? coherenceMaximumDurationSeconds : spotMaximumDurationSeconds
}
}
public struct PPGLiveAcceptedBeat: Equatable, Sendable {
public let sequence: Int
public let timestampSeconds: Double
public let intervalMs: Double
public let confidence: Double
}
/// Deduplicates accepted intervals emitted by overlapping live-analysis windows.
public struct PPGLiveBeatTracker: Sendable {
private var lastPublishedTimestamp: Double?
private var sequence = 0
public init() {}
public mutating func ingest(_ intervals: [PulseInterval]) -> [PPGLiveAcceptedBeat] {
let sorted = intervals
.filter {
$0.isAccepted && $0.endSeconds.isFinite && $0.durationMilliseconds.isFinite
&& (300...2_000).contains($0.durationMilliseconds)
}
.sorted { $0.endSeconds < $1.endSeconds }
let previous = lastPublishedTimestamp
let newIntervals = sorted.filter { interval in
guard let previous else { return true }
// Physiological intervals are at least 300 ms. A 250 ms boundary treats
// small peak refinements as the same beat without swallowing a real one.
return interval.endSeconds > previous + 0.25
}
let beats = newIntervals.map { interval -> PPGLiveAcceptedBeat in
sequence += 1
return PPGLiveAcceptedBeat(
sequence: sequence,
timestampSeconds: interval.endSeconds,
intervalMs: interval.durationMilliseconds,
confidence: min(max(interval.qualityScore, 0), 1)
)
}
if let latest = sorted.last?.endSeconds {
lastPublishedTimestamp = max(lastPublishedTimestamp ?? latest, latest)
}
return beats
}
public mutating func reset() {
lastPublishedTimestamp = nil
sequence = 0
}
}
@@ -0,0 +1,242 @@
import Foundation
/// A deterministic artifact window used by ``PPGSyntheticSignalConfiguration``.
public struct PPGSyntheticArtifactWindow: Codable, Equatable, Sendable {
public var startSeconds: Double
public var endSeconds: Double
public var intensity: Double
public init(startSeconds: Double, endSeconds: Double, intensity: Double = 1) {
self.startSeconds = max(startSeconds.isFinite ? startSeconds : 0, 0)
self.endSeconds = max(endSeconds.isFinite ? endSeconds : 0, self.startSeconds)
self.intensity = min(max(intensity.isFinite ? intensity : 0, 0), 1)
}
func intensity(at time: Double) -> Double {
(startSeconds...endSeconds).contains(time) ? intensity : 0
}
}
/// Configuration for a numeric PPG camera fixture.
///
/// The generator creates the same compact RGB/luminance/time values consumed by
/// production signal processing. It never creates or retains images. All random
/// noise is seeded, so a failing test can be reproduced byte for byte.
public struct PPGSyntheticSignalConfiguration: Codable, Equatable, Sendable {
public var sampleRateHz: Double
public var durationSeconds: Double
public var startingBPM: Double
public var endingBPM: Double
/// Repeating beat-to-beat offsets, in milliseconds, added to the nominal IPI.
public var intervalVariationMilliseconds: [Double]
public var noiseAmplitude: Double
public var driftAmplitude: Double
public var driftFrequencyHz: Double
public var movementWindows: [PPGSyntheticArtifactWindow]
public var saturationWindows: [PPGSyntheticArtifactWindow]
/// Every nth nominal camera frame is omitted. Nil keeps every frame.
public var dropEveryNthFrame: Int?
/// Suppresses the pulse cycle beginning at each listed zero-based beat index.
public var missingBeatIndices: Set<Int>
/// Adds a narrow artificial pulse at each supplied timestamp.
public var falsePeakTimesSeconds: [Double]
public var seed: UInt64
public init(
sampleRateHz: Double = 30,
durationSeconds: Double = 60,
startingBPM: Double = 75,
endingBPM: Double? = nil,
intervalVariationMilliseconds: [Double] = [],
noiseAmplitude: Double = 0,
driftAmplitude: Double = 0,
driftFrequencyHz: Double = 0.05,
movementWindows: [PPGSyntheticArtifactWindow] = [],
saturationWindows: [PPGSyntheticArtifactWindow] = [],
dropEveryNthFrame: Int? = nil,
missingBeatIndices: Set<Int> = [],
falsePeakTimesSeconds: [Double] = [],
seed: UInt64 = 0x4841_5054_494B_4101
) {
self.sampleRateHz = sampleRateHz
self.durationSeconds = durationSeconds
self.startingBPM = startingBPM
self.endingBPM = endingBPM ?? startingBPM
self.intervalVariationMilliseconds = intervalVariationMilliseconds
self.noiseAmplitude = max(noiseAmplitude, 0)
self.driftAmplitude = max(driftAmplitude, 0)
self.driftFrequencyHz = max(driftFrequencyHz, 0)
self.movementWindows = movementWindows
self.saturationWindows = saturationWindows
self.dropEveryNthFrame = dropEveryNthFrame
self.missingBeatIndices = missingBeatIndices
self.falsePeakTimesSeconds = falsePeakTimesSeconds
self.seed = seed
}
public func validated() throws -> Self {
guard sampleRateHz.isFinite, sampleRateHz >= 10, sampleRateHz <= 240 else {
throw PPGSignalError.invalidConfiguration("synthetic sampleRateHz must be in 10...240")
}
guard durationSeconds.isFinite, durationSeconds > 0 else {
throw PPGSignalError.invalidConfiguration("synthetic durationSeconds must be positive")
}
guard startingBPM.isFinite, endingBPM.isFinite,
(30...240).contains(startingBPM), (30...240).contains(endingBPM)
else {
throw PPGSignalError.invalidConfiguration("synthetic BPM must be in 30...240")
}
guard intervalVariationMilliseconds.allSatisfy({ $0.isFinite && abs($0) <= 300 }) else {
throw PPGSignalError.invalidConfiguration("synthetic interval variation must be finite and <= 300 ms")
}
if let dropEveryNthFrame, dropEveryNthFrame < 2 {
throw PPGSignalError.invalidConfiguration("dropEveryNthFrame must be at least 2")
}
return self
}
}
public struct PPGSyntheticSignal: Equatable, Sendable {
public var samples: [PPGFrameSample]
public var beatTimesSeconds: [Double]
public var nominalIntervalsMilliseconds: [Double]
public init(
samples: [PPGFrameSample],
beatTimesSeconds: [Double],
nominalIntervalsMilliseconds: [Double]
) {
self.samples = samples
self.beatTimesSeconds = beatTimesSeconds
self.nominalIntervalsMilliseconds = nominalIntervalsMilliseconds
}
}
public enum PPGSyntheticSignalGenerator {
public static func generate(
configuration input: PPGSyntheticSignalConfiguration
) throws -> PPGSyntheticSignal {
let configuration = try input.validated()
let beatSchedule = makeBeatSchedule(configuration)
let frameCount = Int((configuration.durationSeconds * configuration.sampleRateHz).rounded(.down)) + 1
var random = SeededRandomNumberGenerator(seed: configuration.seed)
var samples: [PPGFrameSample] = []
samples.reserveCapacity(frameCount)
var beatIndex = 0
for frameIndex in 0..<frameCount {
if let nth = configuration.dropEveryNthFrame,
frameIndex > 0, frameIndex.isMultiple(of: nth) {
continue
}
let time = Double(frameIndex) / configuration.sampleRateHz
while beatIndex + 1 < beatSchedule.times.count,
beatSchedule.times[beatIndex + 1] <= time {
beatIndex += 1
}
let currentBeat = beatSchedule.times[min(beatIndex, beatSchedule.times.count - 1)]
let nextBeat = beatIndex + 1 < beatSchedule.times.count
? beatSchedule.times[beatIndex + 1]
: currentBeat + 60 / configuration.endingBPM
let phase = min(max((time - currentBeat) / max(nextBeat - currentBeat, 1e-6), 0), 1)
var pulse = cos(2 * Double.pi * phase) + 0.18 * cos(4 * Double.pi * phase)
if configuration.missingBeatIndices.contains(beatIndex) { pulse = 0 }
for falsePeakTime in configuration.falsePeakTimesSeconds where falsePeakTime.isFinite {
let distance = (time - falsePeakTime) / 0.055
pulse += 1.25 * exp(-0.5 * distance * distance)
}
let drift = configuration.driftAmplitude
* sin(2 * Double.pi * configuration.driftFrequencyHz * time)
let movement = configuration.movementWindows.map { $0.intensity(at: time) }.max() ?? 0
let saturation = configuration.saturationWindows.map { $0.intensity(at: time) }.max() ?? 0
let baseNoise = random.nextGaussian() * configuration.noiseAmplitude
let movementNoise = random.nextGaussian() * movement * 16
let commonLight = 1 + drift + movementNoise * 0.006
var red = 176 * commonLight + 2.8 * pulse + 0.55 * baseNoise + movementNoise
var green = 92 * commonLight + 6.2 * pulse + baseNoise - 0.35 * movementNoise
var blue = 68 * commonLight + 1.8 * pulse + 0.45 * baseNoise + 0.20 * movementNoise
if saturation > 0 {
red = red * (1 - saturation) + 255 * saturation
green = green * (1 - saturation) + 255 * saturation
blue = blue * (1 - saturation) + 230 * saturation
}
red = min(max(red, 0), 255)
green = min(max(green, 0), 255)
blue = min(max(blue, 0), 255)
let luminance = 0.2126 * red + 0.7152 * green + 0.0722 * blue
samples.append(
PPGFrameSample(
timestampSeconds: time,
red: red,
green: green,
blue: blue,
luminance: luminance,
contactConfidence: max(0.95 - 0.25 * movement, 0),
saturationRatio: saturation,
motionArtifactScore: movement
)
)
}
return PPGSyntheticSignal(
samples: samples,
beatTimesSeconds: beatSchedule.times,
nominalIntervalsMilliseconds: beatSchedule.intervals
)
}
private static func makeBeatSchedule(
_ configuration: PPGSyntheticSignalConfiguration
) -> (times: [Double], intervals: [Double]) {
var times = [0.0]
var intervals: [Double] = []
var beatIndex = 0
while let last = times.last, last < configuration.durationSeconds {
let progress = min(max(last / configuration.durationSeconds, 0), 1)
let bpm = configuration.startingBPM
+ (configuration.endingBPM - configuration.startingBPM) * progress
let variation = configuration.intervalVariationMilliseconds.isEmpty
? 0
: configuration.intervalVariationMilliseconds[
beatIndex % configuration.intervalVariationMilliseconds.count
]
let intervalMs = max(60_000 / bpm + variation, 250)
intervals.append(intervalMs)
times.append(last + intervalMs / 1_000)
beatIndex += 1
}
return (times, intervals)
}
}
private struct SeededRandomNumberGenerator: RandomNumberGenerator {
private var state: UInt64
private var spareGaussian: Double?
init(seed: UInt64) {
state = seed == 0 ? 0x9E37_79B9_7F4A_7C15 : seed
}
mutating func next() -> UInt64 {
state &+= 0x9E37_79B9_7F4A_7C15
var value = state
value = (value ^ (value >> 30)) &* 0xBF58_476D_1CE4_E5B9
value = (value ^ (value >> 27)) &* 0x94D0_49BB_1331_11EB
return value ^ (value >> 31)
}
mutating func nextGaussian() -> Double {
if let spareGaussian {
self.spareGaussian = nil
return spareGaussian
}
let u1 = max(Double(next()) / Double(UInt64.max), 1e-12)
let u2 = Double(next()) / Double(UInt64.max)
let radius = sqrt(-2 * log(u1))
let angle = 2 * Double.pi * u2
spareGaussian = radius * sin(angle)
return radius * cos(angle)
}
}
@@ -0,0 +1,143 @@
import Foundation
public struct PRVCalculationResult: Equatable, Sendable {
public var metrics: PRVMetrics
public var intervals: [PulseInterval]
public init(metrics: PRVMetrics, intervals: [PulseInterval]) {
self.metrics = metrics
self.intervals = intervals
}
}
public enum PPGPRVCalculator {
public static func calculate(
intervals inputIntervals: [PulseInterval],
configuration: PPGProcessingConfiguration
) throws -> PRVCalculationResult {
let configuration = try configuration.validated()
var intervals = inputIntervals
let minimumInterval = 1_000 / configuration.maximumFrequencyHz
let maximumInterval = 1_000 / configuration.minimumFrequencyHz
for index in intervals.indices where intervals[index].rejectionReason == nil {
let value = intervals[index].durationMilliseconds
if !value.isFinite || value < minimumInterval || value > maximumInterval {
intervals[index].rejectionReason = .outsidePhysiologicalRange
}
}
// Filter against a moving neighbourhood (±13 intervals), not one global
// median. This follows gradual physiological changes while isolating a
// missed/double peak or a short movement artifact. Decisions use a frozen
// snapshot so one rejected beat cannot cascade into later rejections.
let initiallyAccepted = Set(intervals.indices.filter { intervals[$0].isAccepted })
if initiallyAccepted.count >= 3 {
for index in intervals.indices where initiallyAccepted.contains(index) {
let lower = max(0, index - 13)
let upper = min(intervals.count - 1, index + 13)
let localValues = (lower...upper).compactMap { neighbour -> Double? in
guard neighbour != index, initiallyAccepted.contains(neighbour) else { return nil }
return intervals[neighbour].durationMilliseconds
}
guard localValues.count >= 2 else { continue }
let center = SignalStatistics.median(localValues)
let robustDeviation = 1.4826 * SignalStatistics.mad(localValues, around: center)
let allowedDeviation = max(center * 0.20, 3.5 * robustDeviation, 70)
let value = intervals[index].durationMilliseconds
let resemblesMissedBeat = abs(value - 2 * center) <= max(0.18 * center, 70)
let resemblesDoublePeak = abs(2 * value - center) <= max(0.18 * center, 70)
if abs(value - center) > allowedDeviation
|| resemblesMissedBeat
|| resemblesDoublePeak {
intervals[index].rejectionReason = .localOutlier
}
}
}
let accepted = intervals.filter(\.isAccepted)
let values = accepted.map(\.durationMilliseconds)
let rejectedCount = intervals.count - accepted.count
let rejectedRatio = intervals.isEmpty
? 1
: Double(rejectedCount) / Double(intervals.count)
let median = values.isEmpty ? nil : SignalStatistics.median(values)
let mean = values.isEmpty ? nil : SignalStatistics.mean(values)
let sdnn = values.count >= 2 ? SignalStatistics.standardDeviation(values) : nil
let coefficientOfVariation: Double?
if let median, let sdnn, median > 0 {
coefficientOfVariation = sdnn / median
} else {
coefficientOfVariation = nil
}
var successiveDifferences: [Double] = []
for pair in zip(accepted, accepted.dropFirst()) {
// Do not bridge over a rejected or missing beat: it would create a fictitious PRV difference.
guard pair.1.sourceIndex == pair.0.sourceIndex + 1 else { continue }
let difference = pair.1.durationMilliseconds - pair.0.durationMilliseconds
successiveDifferences.append(difference)
}
let squaredSuccessiveDifferences = successiveDifferences.map { $0 * $0 }
let rmssd = squaredSuccessiveDifferences.isEmpty
? nil
: SignalStatistics.mean(squaredSuccessiveDifferences).squareRoot()
let pnn50Percent = successiveDifferences.isEmpty
? nil
: 100 * Double(successiveDifferences.filter { abs($0) > 50 }.count)
/ Double(successiveDifferences.count)
let rangeIpiMs: Double? = values.count >= 2
? (values.max() ?? 0) - (values.min() ?? 0)
: nil
let histogram = Dictionary(grouping: values) { Int(floor($0 / 50)) }
let modalBin = histogram.max { lhs, rhs in
if lhs.value.count == rhs.value.count { return lhs.key > rhs.key }
return lhs.value.count < rhs.value.count
}
let modeIpiMs = modalBin.map { SignalStatistics.mean($0.value) }
let amo50Percent = modalBin.map {
100 * Double($0.value.count) / Double(values.count)
}
let reliable = values.count >= configuration.minimumIntervalsForPRV
&& rejectedRatio <= configuration.maximumRejectedIntervalRatioForPRV
&& rmssd != nil
&& sdnn != nil
let metrics = PRVMetrics(
estimatedRmssdMs: rmssd,
estimatedSdnnMs: sdnn,
meanIpiMs: mean,
medianIpiMs: median,
pnn50Percent: pnn50Percent,
rangeIpiMs: rangeIpiMs,
modeIpiMs: modeIpiMs,
amo50Percent: amo50Percent,
coefficientOfVariation: coefficientOfVariation,
validIntervalCount: values.count,
rejectedIntervalCount: rejectedCount,
rejectedIntervalRatio: rejectedRatio,
reliable: reliable
)
return PRVCalculationResult(metrics: metrics, intervals: intervals)
}
public static func calculate(
intervalsMilliseconds: [Double],
configuration: PPGProcessingConfiguration
) throws -> PRVMetrics {
var elapsed = 0.0
let intervals = intervalsMilliseconds.enumerated().map { index, duration in
let start = elapsed
elapsed += duration / 1_000
return PulseInterval(
sourceIndex: index,
startSeconds: start,
endSeconds: elapsed,
durationMilliseconds: duration
)
}
return try calculate(intervals: intervals, configuration: configuration).metrics
}
}
@@ -0,0 +1,276 @@
import Foundation
public enum PPGPeakDetector {
public static func detect(
signal: [Double],
timestampsSeconds: [Double],
validityMask: [Bool],
configuration: PPGProcessingConfiguration
) throws -> PeakDetectionResult {
let configuration = try configuration.validated()
guard signal.count == timestampsSeconds.count,
signal.count == validityMask.count,
signal.count >= 5
else {
throw PPGSignalError.insufficientSamples(required: 5, actual: signal.count)
}
let positive = detectPolarity(
signal: signal,
timestampsSeconds: timestampsSeconds,
validityMask: validityMask,
polarity: 1,
configuration: configuration
)
let negative = detectPolarity(
signal: signal,
timestampsSeconds: timestampsSeconds,
validityMask: validityMask,
polarity: -1,
configuration: configuration
)
if negative.confidence > positive.confidence + 1e-12 {
return negative
}
if abs(negative.confidence - positive.confidence) <= 1e-12,
negative.acceptedIntervals.count > positive.acceptedIntervals.count {
return negative
}
return positive
}
private struct Candidate {
var index: Int
var amplitude: Double
var prominence: Double
}
private static func detectPolarity(
signal: [Double],
timestampsSeconds: [Double],
validityMask: [Bool],
polarity: Int,
configuration: PPGProcessingConfiguration
) -> PeakDetectionResult {
let values = signal.map { ($0.isFinite ? $0 : 0) * Double(polarity) }
let timestampIntervals = zip(timestampsSeconds, timestampsSeconds.dropFirst())
.map { $1 - $0 }
.filter { $0.isFinite && $0 > 0 }
let sampleInterval = SignalStatistics.median(timestampIntervals)
guard sampleInterval > 0 else {
return PeakDetectionResult(peaks: [], intervals: [], selectedPolarity: polarity, confidence: 0)
}
let sampleRate = 1 / sampleInterval
let validValues = values.indices.compactMap { validityMask[$0] ? values[$0] : nil }
let center = SignalStatistics.median(validValues)
let robustScale = max(1.4826 * SignalStatistics.mad(validValues, around: center), 1e-6)
let amplitudeThreshold = center + max(configuration.minimumPeakAmplitude, robustScale * 0.18)
let prominenceRadius = max(Int((0.45 * sampleRate).rounded()), 2)
var candidates: [Candidate] = []
candidates.reserveCapacity(max(signal.count / 15, 8))
for index in 1..<(values.count - 1) {
guard validityMask[index],
validityMask[index - 1],
validityMask[index + 1],
values[index] >= amplitudeThreshold,
values[index] > values[index - 1],
values[index] >= values[index + 1]
else { continue }
let leftStart = max(0, index - prominenceRadius)
let rightEnd = min(values.count - 1, index + prominenceRadius)
let leftMinimum = values[leftStart..<index].min() ?? values[index]
let rightMinimum = values[(index + 1)...rightEnd].min() ?? values[index]
let prominence = min(values[index] - leftMinimum, values[index] - rightMinimum)
guard prominence >= configuration.minimumPeakProminence else { continue }
candidates.append(Candidate(index: index, amplitude: values[index], prominence: prominence))
}
let minimumDistance = max(
Int((configuration.refractoryPeriodSeconds * sampleRate).rounded()),
2
)
var selected: [Candidate] = []
for candidate in candidates {
if let last = selected.last, candidate.index - last.index < minimumDistance {
let candidateStrength = candidate.prominence + 0.25 * candidate.amplitude
let lastStrength = last.prominence + 0.25 * last.amplitude
if candidateStrength > lastStrength {
selected[selected.count - 1] = candidate
}
} else {
selected.append(candidate)
}
}
let peaks = selected.map { candidate -> DetectedPeak in
let index = candidate.index
let left = values[index - 1]
let middle = values[index]
let right = values[index + 1]
let denominator = left - 2 * middle + right
let fractionalOffset: Double
if abs(denominator) > 1e-12 {
fractionalOffset = min(max(0.5 * (left - right) / denominator, -0.5), 0.5)
} else {
fractionalOffset = 0
}
let refinedTimestamp = timestampsSeconds[index] + fractionalOffset * sampleInterval
return DetectedPeak(
sampleIndex: index,
timestampSeconds: refinedTimestamp,
amplitude: candidate.amplitude,
prominence: candidate.prominence
)
}
var intervals = zip(peaks, peaks.dropFirst()).enumerated().map { sourceIndex, pair in
let durationMilliseconds = (pair.1.timestampSeconds - pair.0.timestampSeconds) * 1_000
let lowerIndex = min(pair.0.sampleIndex, pair.1.sampleIndex)
let upperIndex = max(pair.0.sampleIndex, pair.1.sampleIndex)
let crossesInvalidRegion = validityMask[lowerIndex...upperIndex].contains(false)
return PulseInterval(
sourceIndex: sourceIndex,
startSeconds: pair.0.timestampSeconds,
endSeconds: pair.1.timestampSeconds,
durationMilliseconds: durationMilliseconds,
rejectionReason: crossesInvalidRegion ? .invalidSignalRegion : nil
)
}
let minimumInterval = 1_000 / configuration.maximumFrequencyHz
let maximumInterval = 1_000 / configuration.minimumFrequencyHz
for index in intervals.indices where intervals[index].rejectionReason == nil {
let duration = intervals[index].durationMilliseconds
if !duration.isFinite || duration < minimumInterval || duration > maximumInterval {
intervals[index].rejectionReason = .outsidePhysiologicalRange
}
}
// Score every pulse independently. A short movement or pressure change can
// therefore reject only the affected beats instead of invalidating the
// complete recording. Cycles are normalized to 50 points before their
// shape is compared with neighbouring cycles, following the published
// morphology-based quality approach used for camera PPG.
let cycleShapes = intervals.indices.map { index in
normalizedCycle(
values: values,
lowerIndex: peaks[index].sampleIndex,
upperIndex: peaks[index + 1].sampleIndex,
outputCount: 50
)
}
let medianProminence = max(SignalStatistics.median(peaks.map(\.prominence)), 1e-9)
for index in intervals.indices {
let lowerPeakQuality = SignalStatistics.clamp01(peaks[index].prominence / medianProminence)
let upperPeakQuality = SignalStatistics.clamp01(peaks[index + 1].prominence / medianProminence)
let prominenceQuality = min(lowerPeakQuality, upperPeakQuality)
let neighbourhood = max(0, index - 3)...min(intervals.count - 1, index + 3)
let correlations = neighbourhood.compactMap { neighbour -> Double? in
guard neighbour != index,
let shape = cycleShapes[index],
let other = cycleShapes[neighbour]
else { return nil }
return shapeCorrelation(shape, other)
}
let morphologyQuality: Double
if correlations.isEmpty {
morphologyQuality = intervals.count < 3 ? 0.75 : 0
} else {
// Use the best two local matches so one adjacent corrupted cycle does
// not drag a clean beat below the acceptance threshold.
let strongest = correlations.sorted(by: >).prefix(2)
morphologyQuality = SignalStatistics.clamp01(
(SignalStatistics.mean(Array(strongest)) + 1) / 2
)
}
let technicalQuality = SignalStatistics.clamp01(
0.65 * morphologyQuality + 0.35 * prominenceQuality
)
intervals[index].qualityScore = technicalQuality
if intervals[index].rejectionReason == nil,
intervals.count >= 3,
technicalQuality < 0.42 {
intervals[index].rejectionReason = .lowMorphologyQuality
}
}
let physiologicallyPlausible = intervals
.filter(\.isAccepted)
.map(\.durationMilliseconds)
if physiologicallyPlausible.count >= 3 {
let median = SignalStatistics.median(physiologicallyPlausible)
let robustDeviation = 1.4826 * SignalStatistics.mad(physiologicallyPlausible, around: median)
let allowedDeviation = max(0.28 * median, 3.5 * robustDeviation, 90)
for index in intervals.indices where intervals[index].rejectionReason == nil {
if abs(intervals[index].durationMilliseconds - median) > allowedDeviation {
intervals[index].rejectionReason = .localOutlier
}
}
}
let accepted = intervals.filter(\.isAccepted)
let acceptedDurations = accepted.map(\.durationMilliseconds)
let intervalAcceptance = intervals.isEmpty
? 0
: Double(accepted.count) / Double(intervals.count)
let countScore = min(Double(accepted.count) / 8, 1)
let regularity: Double
if acceptedDurations.count >= 2 {
let median = max(SignalStatistics.median(acceptedDurations), 1)
let robustCV = 1.4826 * SignalStatistics.mad(acceptedDurations, around: median) / median
regularity = SignalStatistics.clamp01(1 - robustCV / 0.20)
} else {
regularity = 0
}
let prominenceScore = SignalStatistics.clamp01(medianProminence / 1.5)
let validCoverage = Double(validityMask.filter { $0 }.count) / Double(max(validityMask.count, 1))
let confidence = 0.27 * countScore
+ 0.23 * intervalAcceptance
+ 0.20 * regularity
+ 0.20 * prominenceScore
+ 0.10 * validCoverage
return PeakDetectionResult(
peaks: peaks,
intervals: intervals,
selectedPolarity: polarity,
confidence: confidence
)
}
private static func normalizedCycle(
values: [Double],
lowerIndex: Int,
upperIndex: Int,
outputCount: Int
) -> [Double]? {
guard lowerIndex >= 0,
upperIndex < values.count,
upperIndex - lowerIndex >= 3,
outputCount >= 3
else { return nil }
let span = Double(upperIndex - lowerIndex)
let sampled = (0..<outputCount).map { outputIndex -> Double in
let position = Double(lowerIndex)
+ Double(outputIndex) * span / Double(outputCount - 1)
let left = min(max(Int(position.rounded(.down)), lowerIndex), upperIndex)
let right = min(left + 1, upperIndex)
let fraction = position - Double(left)
return values[left] + fraction * (values[right] - values[left])
}
let center = SignalStatistics.mean(sampled)
let scale = SignalStatistics.standardDeviation(sampled)
guard scale.isFinite, scale > 1e-8 else { return nil }
return sampled.map { ($0 - center) / scale }
}
private static func shapeCorrelation(_ lhs: [Double], _ rhs: [Double]) -> Double? {
guard lhs.count == rhs.count, lhs.count >= 3 else { return nil }
let value = zip(lhs, rhs).reduce(0.0) { $0 + $1.0 * $1.1 } / Double(lhs.count)
return value.isFinite ? min(max(value, -1), 1) : nil
}
}
@@ -0,0 +1,135 @@
import Foundation
public enum PPGSignalResampler {
/// Resamples irregular samples onto a uniform time grid using the real frame timestamps.
/// Values spanning a long gap are numerically interpolated to keep downstream filters finite,
/// but the corresponding validity mask remains false so no beat can be accepted there.
public static func resample(
_ samples: [TimestampedValue],
targetSampleRateHz: Double,
maximumInterpolationGapSeconds: Double
) throws -> ResampledSignal {
guard targetSampleRateHz.isFinite, targetSampleRateHz > 0 else {
throw PPGSignalError.invalidConfiguration("targetSampleRateHz must be positive")
}
guard maximumInterpolationGapSeconds.isFinite, maximumInterpolationGapSeconds > 0 else {
throw PPGSignalError.invalidConfiguration("maximumInterpolationGapSeconds must be positive")
}
let finite = samples.enumerated().compactMap { index, sample -> (Int, TimestampedValue)? in
guard sample.timestampSeconds.isFinite, sample.value.isFinite else { return nil }
return (index, sample)
}
guard finite.count >= 3 else {
throw PPGSignalError.insufficientSamples(required: 3, actual: finite.count)
}
let outOfOrderCount = zip(finite, finite.dropFirst()).reduce(0) { count, pair in
count + (pair.1.1.timestampSeconds < pair.0.1.timestampSeconds ? 1 : 0)
}
let sorted = finite.sorted {
if $0.1.timestampSeconds == $1.1.timestampSeconds { return $0.0 < $1.0 }
return $0.1.timestampSeconds < $1.1.timestampSeconds
}
var unique: [TimestampedValue] = []
unique.reserveCapacity(sorted.count)
var duplicateTimestampCount = 0
for (_, sample) in sorted {
if let last = unique.last, sample.timestampSeconds - last.timestampSeconds <= 1e-9 {
duplicateTimestampCount += 1
continue
}
unique.append(sample)
}
guard unique.count >= 3 else {
throw PPGSignalError.invalidTimestamps
}
let intervals = zip(unique, unique.dropFirst()).map {
$1.timestampSeconds - $0.timestampSeconds
}
guard intervals.allSatisfy({ $0.isFinite && $0 > 0 }) else {
throw PPGSignalError.invalidTimestamps
}
let medianInterval = SignalStatistics.median(intervals)
guard medianInterval > 0 else { throw PPGSignalError.invalidTimestamps }
let intervalMAD = SignalStatistics.mad(intervals, around: medianInterval)
let maximumGap = intervals.max() ?? 0
let duration = unique.last!.timestampSeconds - unique.first!.timestampSeconds
var inferredMissingFrames = 0
var longGapDuration = 0.0
for interval in intervals {
if interval > medianInterval * 1.5 {
inferredMissingFrames += max(Int((interval / medianInterval).rounded()) - 1, 0)
}
if interval > maximumInterpolationGapSeconds {
longGapDuration += max(interval - medianInterval, 0)
}
}
let inferredTotalFrames = unique.count + inferredMissingFrames
let droppedFrameRatio = inferredTotalFrames > 0
? Double(inferredMissingFrames) / Double(inferredTotalFrames)
: 0
let usableDuration = max(duration - longGapDuration, 0)
let step = 1 / targetSampleRateHz
let outputCount = max(Int((duration * targetSampleRateHz).rounded(.down)) + 1, 1)
var timestamps: [Double] = []
var values: [Double] = []
var validityMask: [Bool] = []
timestamps.reserveCapacity(outputCount)
values.reserveCapacity(outputCount)
validityMask.reserveCapacity(outputCount)
var upperIndex = 1
let firstTimestamp = unique[0].timestampSeconds
for outputIndex in 0..<outputCount {
let timestamp = firstTimestamp + Double(outputIndex) * step
while upperIndex < unique.count - 1,
unique[upperIndex].timestampSeconds < timestamp {
upperIndex += 1
}
let upper = unique[upperIndex]
let lower = unique[upperIndex - 1]
let gap = upper.timestampSeconds - lower.timestampSeconds
let fraction = gap > 0 ? (timestamp - lower.timestampSeconds) / gap : 0
let clampedFraction = min(max(fraction, 0), 1)
let value = lower.value + clampedFraction * (upper.value - lower.value)
let liesInsideSourceRange = timestamp >= lower.timestampSeconds - 1e-9
&& timestamp <= upper.timestampSeconds + 1e-9
let isExactSourceSample = abs(timestamp - lower.timestampSeconds) <= step * 1e-6
|| abs(timestamp - upper.timestampSeconds) <= step * 1e-6
let isValid = liesInsideSourceRange
&& (isExactSourceSample || gap <= maximumInterpolationGapSeconds)
timestamps.append(timestamp)
values.append(value.isFinite ? value : 0)
validityMask.append(isValid)
}
let diagnostics = TimebaseDiagnostics(
inputSampleCount: samples.count,
usableInputSampleCount: unique.count,
discardedSampleCount: samples.count - finite.count,
duplicateTimestampCount: duplicateTimestampCount,
outOfOrderTimestampCount: outOfOrderCount,
durationSeconds: duration,
usableDurationSeconds: usableDuration,
estimatedSampleRateHz: 1 / medianInterval,
frameIntervalMADSeconds: intervalMAD,
droppedFrameRatio: droppedFrameRatio,
maximumGapSeconds: maximumGap,
longGapDurationSeconds: longGapDuration
)
return ResampledSignal(
timestampsSeconds: timestamps,
values: values,
validityMask: validityMask,
diagnostics: diagnostics
)
}
}
@@ -0,0 +1,331 @@
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..<edgeGuard { validityMask[index] = false }
for index in (validityMask.count - edgeGuard)..<validityMask.count {
validityMask[index] = false
}
}
let peakDetection = try PPGPeakDetector.detect(
signal: filtered,
timestampsSeconds: resampled.timestampsSeconds,
validityMask: validityMask,
configuration: configuration
)
let bpm = try PPGBPMEstimator.estimate(
signal: filtered,
timestampsSeconds: resampled.timestampsSeconds,
validityMask: validityMask,
peakDetection: peakDetection,
selectedChannel: channel,
configuration: configuration
)
let snrDB = bpm.frequencyBpm.flatMap { frequencyBPM in
SignalStatistics.periodicBandSNRDB(
values: filtered,
timestamps: resampled.timestampsSeconds,
validityMask: validityMask,
fundamentalFrequencyHz: frequencyBPM / 60,
minimumFrequencyHz: configuration.minimumFrequencyHz,
maximumFrequencyHz: configuration.maximumFrequencyHz
)
}
let snrScore = snrDB.map { SignalStatistics.clamp01(($0 + 3) / 15) } ?? 0
let coverage = Double(validityMask.filter { $0 }.count) / Double(max(validityMask.count, 1))
let acceptedIntervals = peakDetection.acceptedIntervals
let intervalAcceptance = peakDetection.intervals.isEmpty
? 0
: Double(acceptedIntervals.count) / Double(peakDetection.intervals.count)
let beatQuality = acceptedIntervals.isEmpty
? 0
: SignalStatistics.mean(acceptedIntervals.map(\.qualityScore))
let selectionScore = SignalStatistics.clamp01(
0.42 * bpm.confidence
+ 0.23 * snrScore
+ 0.13 * coverage
+ 0.12 * intervalAcceptance
+ 0.10 * beatQuality
)
return ChannelCandidate(
channel: channel,
filteredSignal: filtered,
validityMask: validityMask,
timebase: resampled.diagnostics,
peakDetection: peakDetection,
bpm: bpm,
snrDB: snrDB,
selectionScore: selectionScore
)
}
private func resample(
_ samples: [PPGFrameSample],
value keyPath: KeyPath<PPGFrameSample, Double>
) 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
}
}
@@ -0,0 +1,176 @@
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)
}
}
@@ -0,0 +1,196 @@
import Foundation
enum SignalStatistics {
static func mean(_ values: [Double]) -> Double {
guard !values.isEmpty else { return 0 }
return values.reduce(0, +) / Double(values.count)
}
static func median(_ values: [Double]) -> Double {
guard !values.isEmpty else { return 0 }
let sorted = values.sorted()
let middle = sorted.count / 2
if sorted.count.isMultiple(of: 2) {
return (sorted[middle - 1] + sorted[middle]) / 2
}
return sorted[middle]
}
static func mad(_ values: [Double], around center: Double? = nil) -> Double {
guard !values.isEmpty else { return 0 }
let center = center ?? median(values)
return median(values.map { abs($0 - center) })
}
static func standardDeviation(_ values: [Double], sample: Bool = true) -> Double {
guard values.count > (sample ? 1 : 0) else { return 0 }
let average = mean(values)
let sumSquares = values.reduce(0) { partial, value in
let delta = value - average
return partial + delta * delta
}
let divisor = Double(values.count - (sample ? 1 : 0))
return (sumSquares / divisor).squareRoot()
}
static func rootMeanSquare(_ values: [Double]) -> Double {
guard !values.isEmpty else { return 0 }
let squareMean = values.reduce(0) { $0 + $1 * $1 } / Double(values.count)
return squareMean.squareRoot()
}
static func percentile(_ values: [Double], _ probability: Double) -> Double {
guard !values.isEmpty else { return 0 }
let sorted = values.sorted()
let p = min(max(probability, 0), 1)
let position = p * Double(sorted.count - 1)
let lower = Int(position.rounded(.down))
let upper = Int(position.rounded(.up))
guard lower != upper else { return sorted[lower] }
let fraction = position - Double(lower)
return sorted[lower] + fraction * (sorted[upper] - sorted[lower])
}
static func clamp01(_ value: Double) -> Double {
guard value.isFinite else { return 0 }
return min(max(value, 0), 1)
}
static func reflectedIndex(_ index: Int, count: Int) -> Int {
guard count > 1 else { return 0 }
var candidate = index
while candidate < 0 || candidate >= count {
if candidate < 0 {
candidate = -candidate - 1
}
if candidate >= count {
candidate = 2 * count - candidate - 1
}
}
return candidate
}
/// Fits a sinusoid at `frequencyHz` and reports its power relative to residual power in dB.
static func sinusoidalSNRDB(
values: [Double],
timestamps: [Double],
validityMask: [Bool],
frequencyHz: Double
) -> Double? {
guard values.count == timestamps.count,
values.count == validityMask.count,
frequencyHz.isFinite,
frequencyHz > 0
else { return nil }
let validIndices = values.indices.filter {
validityMask[$0] && values[$0].isFinite && timestamps[$0].isFinite
}
guard validIndices.count >= 12 else { return nil }
let average = validIndices.reduce(0) { $0 + values[$1] } / Double(validIndices.count)
var sinSin = 0.0
var cosCos = 0.0
var sinCos = 0.0
var valueSin = 0.0
var valueCos = 0.0
let origin = timestamps[validIndices[0]]
for index in validIndices {
let angle = 2 * Double.pi * frequencyHz * (timestamps[index] - origin)
let sine = sin(angle)
let cosine = cos(angle)
let value = values[index] - average
sinSin += sine * sine
cosCos += cosine * cosine
sinCos += sine * cosine
valueSin += value * sine
valueCos += value * cosine
}
let determinant = sinSin * cosCos - sinCos * sinCos
guard abs(determinant) > 1e-12 else { return nil }
let sineCoefficient = (valueSin * cosCos - valueCos * sinCos) / determinant
let cosineCoefficient = (valueCos * sinSin - valueSin * sinCos) / determinant
var modeledPower = 0.0
var residualPower = 0.0
for index in validIndices {
let angle = 2 * Double.pi * frequencyHz * (timestamps[index] - origin)
let modeled = sineCoefficient * sin(angle) + cosineCoefficient * cos(angle)
let value = values[index] - average
modeledPower += modeled * modeled
let residual = value - modeled
residualPower += residual * residual
}
guard modeledPower > 1e-12 else { return -60 }
let ratio = modeledPower / max(residualPower, 1e-12)
return 10 * log10(ratio)
}
/// Compares average spectral power around the pulse fundamental (and its second harmonic)
/// with the remaining physiological band. The finite bandwidth tolerates slow BPM changes.
static func periodicBandSNRDB(
values: [Double],
timestamps: [Double],
validityMask: [Bool],
fundamentalFrequencyHz: Double,
minimumFrequencyHz: Double,
maximumFrequencyHz: Double
) -> Double? {
guard values.count == timestamps.count,
values.count == validityMask.count,
values.count >= 30,
fundamentalFrequencyHz.isFinite,
fundamentalFrequencyHz > 0,
minimumFrequencyHz > 0,
maximumFrequencyHz > minimumFrequencyHz
else { return nil }
let validIndices = values.indices.filter {
validityMask[$0] && values[$0].isFinite && timestamps[$0].isFinite
}
guard validIndices.count >= 30 else { return nil }
let origin = timestamps[validIndices[0]]
let duration = timestamps[validIndices.last!] - origin
guard duration > 2 else { return nil }
let average = validIndices.reduce(0) { $0 + values[$1] } / Double(validIndices.count)
let frequencyStep = max(1 / duration, 0.02)
let fundamentalHalfWidth = max(0.12, min(0.28, fundamentalFrequencyHz * 0.18))
let secondHarmonic = 2 * fundamentalFrequencyHz
let harmonicHalfWidth = min(0.36, 1.5 * fundamentalHalfWidth)
var signalPowers: [Double] = []
var noisePowers: [Double] = []
var frequency = minimumFrequencyHz
while frequency <= maximumFrequencyHz + frequencyStep * 0.5 {
var real = 0.0
var imaginary = 0.0
for index in validIndices {
let relativeTime = timestamps[index] - origin
let window = 0.5 - 0.5 * cos(2 * Double.pi * relativeTime / duration)
let centeredValue = (values[index] - average) * window
let angle = -2 * Double.pi * frequency * relativeTime
real += centeredValue * cos(angle)
imaginary += centeredValue * sin(angle)
}
let power = real * real + imaginary * imaginary
let nearFundamental = abs(frequency - fundamentalFrequencyHz) <= fundamentalHalfWidth
let nearSecondHarmonic = secondHarmonic <= maximumFrequencyHz
&& abs(frequency - secondHarmonic) <= harmonicHalfWidth
if nearFundamental || nearSecondHarmonic {
signalPowers.append(power)
} else {
noisePowers.append(power)
}
frequency += frequencyStep
}
guard !signalPowers.isEmpty, !noisePowers.isEmpty else { return nil }
let signalPower = mean(signalPowers)
let noisePower = mean(noisePowers)
guard signalPower > 1e-12 else { return -60 }
return 10 * log10(signalPower / max(noisePower, 1e-12))
}
}