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