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,887 @@
import CoreMedia
import Foundation
import NitroModules
import VisionCamera
/// Nitro entry point for camera PPG processing.
///
/// A `HybridFrameSpec` and its `CMSampleBuffer` exist only as local values during
/// `processFrame(frame:)`. Persistent state contains compact numeric metrics only.
final class HybridHaptikaPPGFrameProcessor: HybridHaptikaPPGFrameProcessorSpec {
private enum SessionPhase {
case idle
case active
case thermalStopped
case finishing
}
private struct MeasurementConfiguration: Sendable {
let sessionPurpose: PPGSessionPurpose
let targetSampleRate: Double
let minimumFrequencyHz: Double
let maximumFrequencyHz: Double
let stabilizationSeconds: Double
let measurementSeconds: Double
let emitEveryFrames: Int
let minimumContactConfidence: Double
let maximumInterpolationGapSeconds: Double
let longGapLimitSeconds: Double
let maximumBufferedSampleCount: Int
init(native: NativePPGConfiguration) throws {
guard native.targetSampleRate.isFinite,
native.targetSampleRate >= 20,
native.targetSampleRate <= 120
else {
throw HaptikaPPGFrameProcessorError.invalidConfiguration(
"targetSampleRate must be between 20 and 120 Hz"
)
}
guard native.minimumFrequencyHz.isFinite,
native.maximumFrequencyHz.isFinite,
native.minimumFrequencyHz > 0,
native.maximumFrequencyHz > native.minimumFrequencyHz,
native.maximumFrequencyHz < native.targetSampleRate / 2
else {
throw HaptikaPPGFrameProcessorError.invalidConfiguration(
"the physiological band must be ordered and below Nyquist"
)
}
guard native.stabilizationSeconds.isFinite,
native.stabilizationSeconds >= 0,
native.stabilizationSeconds <= 30
else {
throw HaptikaPPGFrameProcessorError.invalidConfiguration(
"stabilizationSeconds must be between 0 and 30"
)
}
let maximumDuration = PPGCoherencePolicy.maximumDurationSeconds(
isCoherence: native.sessionPurpose == .coherence
)
guard native.measurementSeconds.isFinite,
native.measurementSeconds >= 20,
native.measurementSeconds <= maximumDuration
else {
throw HaptikaPPGFrameProcessorError.invalidConfiguration(
"measurementSeconds exceeds the limit for this session purpose"
)
}
guard native.emitEveryFrames.isFinite,
native.emitEveryFrames >= 1,
native.emitEveryFrames.rounded() == native.emitEveryFrames,
native.emitEveryFrames <= 10_000
else {
throw HaptikaPPGFrameProcessorError.invalidConfiguration(
"emitEveryFrames must be a positive integer"
)
}
guard native.minimumContactConfidence.isFinite,
(0...1).contains(native.minimumContactConfidence)
else {
throw HaptikaPPGFrameProcessorError.invalidConfiguration(
"minimumContactConfidence must be between 0 and 1"
)
}
sessionPurpose = native.sessionPurpose
targetSampleRate = native.targetSampleRate
minimumFrequencyHz = native.minimumFrequencyHz
maximumFrequencyHz = native.maximumFrequencyHz
stabilizationSeconds = native.stabilizationSeconds
measurementSeconds = native.measurementSeconds
emitEveryFrames = Int(native.emitEveryFrames)
minimumContactConfidence = native.minimumContactConfidence
maximumInterpolationGapSeconds = 0.25
longGapLimitSeconds = 0.80
// This bound permits a full measurement plus recovery time after contact interruptions.
let maximumBufferedDuration = sessionPurpose == .coherence
? PPGCoherencePolicy.coherenceBufferDurationSeconds
: stabilizationSeconds + measurementSeconds + 60
maximumBufferedSampleCount = max(
Int((targetSampleRate * maximumBufferedDuration).rounded(.up)),
256
)
}
var coreConfiguration: PPGProcessingConfiguration {
PPGProcessingConfiguration(
targetSampleRateHz: targetSampleRate,
minimumFrequencyHz: minimumFrequencyHz,
maximumFrequencyHz: maximumFrequencyHz,
// The UI may finish as soon as 50 clean beats have been collected.
// Keep a 45 s floor for HRV while allowing completion before the 90 s
// safety limit on a clean signal.
minimumAnalysisDurationSeconds: min(max(30, measurementSeconds * 0.75), 45),
maximumInterpolationGapSeconds: maximumInterpolationGapSeconds,
longGapLimitSeconds: longGapLimitSeconds,
minimumContactConfidence: minimumContactConfidence,
maximumSaturationRatio: 0.95,
maximumMotionArtifactScore: 0.95,
minimumPeakProminence: 0.35,
minimumPeakAmplitude: 0.10,
refractoryPeriodSeconds: 0.25,
maximumMethodDisagreementBPM: 8,
minimumQualityScore: 0.55,
minimumIntervalsForPRV: 35,
maximumRejectedIntervalRatioForPRV: 0.45
)
}
}
private struct SessionSnapshot: Sendable {
let configuration: MeasurementConfiguration
let samples: [PPGFrameSample]
let startedAtMonotonicSeconds: Double
let endedAtMonotonicSeconds: Double
let processedFrameCount: Int
let droppedFrameCount: Int
let acquisitionDroppedFrameCount: Int
let usableDurationSeconds: Double
let maximumAcquisitionGapSeconds: Double
let bufferLimitReached: Bool
let terminalReason: String?
}
private let stateLock = NSLock()
private let metricsExtractor = PPGFrameMetricsExtractor()
private let thermalMonitor = PPGThermalMonitor()
private var phase: SessionPhase = .idle
private var configuration: MeasurementConfiguration?
private var contactDetector: PPGFingerContactDetector?
private var terminalThermalState: ThermalState?
private var numericSamples: [PPGFrameSample] = []
private var startedAtMonotonicSeconds = 0.0
private var terminalReason: String?
private var bufferLimitReached = false
private var processedFrameCount = 0
private var droppedFrameCount = 0
private var acquisitionDroppedFrameCount = 0
private var lastObservedTimestamp: Double?
private var recentFrameIntervals: [Double] = []
private var maximumAcquisitionGapSeconds = 0.0
private var validContactStartedAt: Double?
private var acquisitionStartedAt: Double?
private var lastUsableTimestamp: Double?
private var usableDurationSeconds = 0.0
private var lastMetrics: PPGFrameColorMetrics?
private var lastContactEvaluation: PPGContactEvaluation?
private var liveBpm: Double?
private var liveIpiMs: Double?
private var liveBeatSequence = 0
private var lastLiveAnalysisFrame = 0
private var liveBeatTracker = PPGLiveBeatTracker()
private var pendingLiveBeats: [NativeAcceptedBeat] = []
private var liveWindowQuality: Double?
private var totalCollectedSampleCount = 0
override init() {
recentFrameIntervals.reserveCapacity(90)
super.init()
}
func startMeasurement(configuration nativeConfiguration: NativePPGConfiguration) throws {
let parsedConfiguration = try MeasurementConfiguration(native: nativeConfiguration)
let thermalState = thermalMonitor.currentState()
guard !Self.isUnsafe(thermalState) else {
throw HaptikaPPGFrameProcessorError.unsafeThermalState(thermalState.stringValue)
}
stateLock.lock()
defer { stateLock.unlock() }
resetLocked()
configuration = parsedConfiguration
contactDetector = PPGFingerContactDetector(
minimumConfidence: parsedConfiguration.minimumContactConfidence
)
numericSamples.reserveCapacity(
min(
parsedConfiguration.maximumBufferedSampleCount,
Int((parsedConfiguration.targetSampleRate * parsedConfiguration.measurementSeconds).rounded(.up))
)
)
startedAtMonotonicSeconds = ProcessInfo.processInfo.systemUptime
phase = .active
}
func processFrame(frame: any HybridFrameSpec) throws -> PPGFrameUpdate? {
stateLock.lock()
defer { stateLock.unlock() }
let thermalState = thermalMonitor.currentState()
if phase == .thermalStopped {
// Keep notifying the worklet until React Native acknowledges the terminal state and tears
// the capture session down. A single callback could be lost while the JS runtime is busy.
return makeFrameUpdate(
timestampSeconds: Self.finiteTimestamp(frame.timestamp, fallback: lastObservedTimestamp),
thermalState: terminalThermalState ?? thermalState,
metrics: lastMetrics,
evaluation: lastContactEvaluation
)
}
guard phase == .active, let configuration, let contactDetector else {
// Camera teardown and Nitro calls can overlap by a frame; ignoring it is safe and avoids
// retaining an already-finished frame.
return nil
}
if Self.isUnsafe(thermalState) {
phase = .thermalStopped
terminalThermalState = thermalState
terminalReason = "thermal_\(thermalState.stringValue)"
processedFrameCount += 1
return makeFrameUpdate(
timestampSeconds: Self.finiteTimestamp(frame.timestamp, fallback: lastObservedTimestamp),
thermalState: thermalState,
metrics: lastMetrics,
evaluation: lastContactEvaluation
)
}
guard frame.isValid,
let nativeFrame = frame as? any NativeFrame,
let sampleBuffer = nativeFrame.sampleBuffer
else {
throw HaptikaPPGFrameProcessorError.frameUnavailable
}
// The sample buffer is intentionally scoped to this call. The extractor returns value-only
// metrics and never stores the CMSampleBuffer or CVPixelBuffer.
let metrics = try metricsExtractor.extract(from: sampleBuffer)
processedFrameCount += 1
let shouldEmit = processedFrameCount.isMultiple(of: configuration.emitEveryFrames)
guard recordFrameTimingLocked(
timestampSeconds: metrics.timestampSeconds,
configuration: configuration
) else {
if shouldEmit {
return makeFrameUpdate(
timestampSeconds: lastObservedTimestamp ?? metrics.timestampSeconds,
thermalState: thermalState,
metrics: lastMetrics,
evaluation: lastContactEvaluation
)
}
return nil
}
let evaluation = contactDetector.evaluate(metrics)
lastMetrics = metrics
lastContactEvaluation = evaluation
updateAcquisitionLocked(
metrics: metrics,
evaluation: evaluation,
configuration: configuration
)
updateLiveEstimateLocked(configuration: configuration)
guard shouldEmit else { return nil }
return makeFrameUpdate(
timestampSeconds: metrics.timestampSeconds,
thermalState: thermalState,
metrics: metrics,
evaluation: evaluation
)
}
func finishMeasurement() throws -> NativePPGProcessingResult {
let snapshot: SessionSnapshot
stateLock.lock()
switch phase {
case .active, .thermalStopped:
guard let configuration else {
stateLock.unlock()
throw HaptikaPPGFrameProcessorError.measurementNotStarted
}
phase = .finishing
snapshot = SessionSnapshot(
configuration: configuration,
samples: numericSamples,
startedAtMonotonicSeconds: startedAtMonotonicSeconds,
endedAtMonotonicSeconds: ProcessInfo.processInfo.systemUptime,
processedFrameCount: processedFrameCount,
droppedFrameCount: droppedFrameCount,
acquisitionDroppedFrameCount: acquisitionDroppedFrameCount,
usableDurationSeconds: usableDurationSeconds,
maximumAcquisitionGapSeconds: maximumAcquisitionGapSeconds,
bufferLimitReached: bufferLimitReached,
terminalReason: terminalReason
)
stateLock.unlock()
case .idle, .finishing:
stateLock.unlock()
throw HaptikaPPGFrameProcessorError.measurementNotStarted
}
defer { resetAfterFinishing() }
if let terminalReason = snapshot.terminalReason {
return makeInvalidResult(
snapshot: snapshot,
reason: terminalReason,
issue: "durationInsufficient"
)
}
if snapshot.bufferLimitReached {
return makeInvalidResult(
snapshot: snapshot,
reason: "The numeric measurement buffer reached its safety limit.",
issue: "nonFiniteProcessingResult"
)
}
do {
let processor = try PPGSignalProcessor(
configuration: snapshot.configuration.coreConfiguration
)
let analysis = try processor.analyze(samples: snapshot.samples)
return makeNativeResult(analysis: analysis, snapshot: snapshot)
} catch {
let mapped = Self.mapProcessingError(error)
return makeInvalidResult(
snapshot: snapshot,
reason: mapped.reason,
issue: mapped.issue
)
}
}
func cancelMeasurement() throws {
stateLock.lock()
resetLocked()
stateLock.unlock()
}
func getThermalState() throws -> ThermalState {
thermalMonitor.currentState()
}
private func recordFrameTimingLocked(
timestampSeconds: Double,
configuration: MeasurementConfiguration
) -> Bool {
guard timestampSeconds.isFinite, timestampSeconds >= 0 else {
droppedFrameCount += 1
if acquisitionStartedAt != nil { acquisitionDroppedFrameCount += 1 }
return false
}
guard let previousTimestamp = lastObservedTimestamp else {
lastObservedTimestamp = timestampSeconds
return true
}
let interval = timestampSeconds - previousTimestamp
guard interval > 0 else {
droppedFrameCount += 1
if acquisitionStartedAt != nil { acquisitionDroppedFrameCount += 1 }
return false
}
if acquisitionStartedAt != nil {
maximumAcquisitionGapSeconds = max(maximumAcquisitionGapSeconds, interval)
}
if recentFrameIntervals.count >= 5 {
let expectedInterval = SignalStatistics.median(recentFrameIntervals)
if expectedInterval > 0, interval > expectedInterval * 1.5 {
let estimatedMissing = min(
max((interval / expectedInterval).rounded() - 1, 0),
100_000
)
let missingCount = Int(estimatedMissing)
droppedFrameCount += missingCount
if acquisitionStartedAt != nil { acquisitionDroppedFrameCount += missingCount }
}
}
if interval <= max(configuration.maximumInterpolationGapSeconds, 0.25) {
recentFrameIntervals.append(interval)
if recentFrameIntervals.count > 90 {
recentFrameIntervals.removeFirst(recentFrameIntervals.count - 90)
}
}
lastObservedTimestamp = timestampSeconds
return true
}
private func updateAcquisitionLocked(
metrics: PPGFrameColorMetrics,
evaluation: PPGContactEvaluation,
configuration: MeasurementConfiguration
) {
// Acquisition is intentionally clock-based rather than contact-gated. A
// contact detector is useful feedback, but making it a hard gate can leave
// a complete minute with zero samples on cameras whose color response is
// different. The signal processor still rejects low-SNR, saturated and
// motion-corrupted portions at the end.
// Store compact numeric samples from the first camera frame. The final
// processor decides which portions are usable. Do not derive a camera
// frame count from the 100 Hz resampling target, and do not throw away a
// good signal merely because the contact heuristic is conservative.
if acquisitionStartedAt == nil {
acquisitionStartedAt = metrics.timestampSeconds
lastUsableTimestamp = nil
}
guard acquisitionStartedAt != nil else { return }
let sample = PPGFrameSample(
timestampSeconds: metrics.timestampSeconds,
red: metrics.redMean,
green: metrics.greenMean,
blue: metrics.blueMean,
luminance: metrics.luminanceMean,
contactConfidence: evaluation.confidence,
saturationRatio: metrics.saturationRatio,
motionArtifactScore: evaluation.motionArtifactScore
)
totalCollectedSampleCount += 1
if configuration.sessionPurpose == .coherence {
numericSamples.append(sample)
if numericSamples.count > configuration.maximumBufferedSampleCount {
numericSamples.removeFirst(numericSamples.count - configuration.maximumBufferedSampleCount)
}
} else if numericSamples.count < configuration.maximumBufferedSampleCount {
numericSamples.append(sample)
} else {
bufferLimitReached = true
}
let opticallyUsable = evaluation.confidence >= 0.35
&& metrics.saturationRatio <= 0.95
&& evaluation.motionArtifactScore <= 0.90
if opticallyUsable {
if let previousTimestamp = lastUsableTimestamp {
let interval = metrics.timestampSeconds - previousTimestamp
if interval > 0, interval <= configuration.maximumInterpolationGapSeconds {
usableDurationSeconds += interval
}
}
lastUsableTimestamp = metrics.timestampSeconds
} else {
// Never bridge quality time across a finger removal or a large movement.
lastUsableTimestamp = nil
}
}
private func makeFrameUpdate(
timestampSeconds: Double,
thermalState: ThermalState,
metrics: PPGFrameColorMetrics?,
evaluation: PPGContactEvaluation?
) -> PPGFrameUpdate {
let signalLevel = min(max((evaluation?.pulsatileAmplitude ?? 0) / 0.03, 0), 1)
let waveformValue: Double
if let metrics {
waveformValue = metrics.greenMean / max(metrics.redMean + metrics.greenMean + metrics.blueMean, 1)
} else {
waveformValue = 0
}
return PPGFrameUpdate(
timestampSeconds: timestampSeconds,
contactState: evaluation?.state ?? .absent,
contactConfidence: min(max(evaluation?.confidence ?? 0, 0), 1),
signalLevel: signalLevel,
saturationRatio: min(max(metrics?.saturationRatio ?? 0, 0), 1),
motionArtifactScore: min(max(evaluation?.motionArtifactScore ?? 0, 0), 1),
collectedSampleCount: Double(totalCollectedSampleCount),
usableDurationSeconds: usableDurationSeconds,
droppedFrameCount: Double(droppedFrameCount),
thermalState: thermalState,
waveformValue: waveformValue,
liveBpm: liveBpm,
liveIpiMs: liveIpiMs,
beatSequence: Double(liveBeatSequence),
acceptedBeats: takePendingLiveBeatsLocked(),
liveWindowQuality: liveWindowQuality
)
}
private func takePendingLiveBeatsLocked() -> [NativeAcceptedBeat] {
let beats = pendingLiveBeats
pendingLiveBeats.removeAll(keepingCapacity: true)
return beats
}
private func updateLiveEstimateLocked(configuration: MeasurementConfiguration) {
// Camera input is constrained to 30 fps. The final 100 Hz configuration
// is a resampling timebase and must not delay live BPM warm-up to 23 s.
let liveInputSampleRate = 30.0
guard numericSamples.count >= Int(liveInputSampleRate * 7),
processedFrameCount - lastLiveAnalysisFrame >= 15
else { return }
lastLiveAnalysisFrame = processedFrameCount
let maximumWindowCount = Int(liveInputSampleRate * 15)
let window = Array(numericSamples.suffix(maximumWindowCount))
let liveConfiguration = PPGProcessingConfiguration(
// A lightweight 30 Hz pass is enough for the live display. The final
// analysis still uses the regular 100 Hz timebase.
targetSampleRateHz: min(configuration.targetSampleRate, 30),
minimumFrequencyHz: configuration.minimumFrequencyHz,
maximumFrequencyHz: configuration.maximumFrequencyHz,
minimumAnalysisDurationSeconds: 6,
maximumInterpolationGapSeconds: configuration.maximumInterpolationGapSeconds,
longGapLimitSeconds: configuration.longGapLimitSeconds,
minimumContactConfidence: min(configuration.minimumContactConfidence, 0.45),
maximumSaturationRatio: 0.55,
maximumMotionArtifactScore: 0.65,
minimumPeakProminence: 0.28,
minimumPeakAmplitude: 0.08,
refractoryPeriodSeconds: 0.30,
maximumMethodDisagreementBPM: 12,
minimumQualityScore: 0.35,
minimumIntervalsForPRV: 4,
maximumRejectedIntervalRatioForPRV: 0.45,
filterTapCount: 31
)
guard let analysis = try? PPGSignalProcessor(configuration: liveConfiguration)
.analyze(samples: window)
else { return }
liveWindowQuality = analysis.quality.score
if let bpm = analysis.bpm.bpm ?? analysis.bpm.temporalBpm,
bpm.isFinite, (35...220).contains(bpm) {
liveBpm = bpm
}
let acceptedIntervals = analysis.intervals.filter(\.isAccepted)
if let interval = acceptedIntervals.last,
interval.durationMilliseconds.isFinite {
liveIpiMs = interval.durationMilliseconds
}
// The live analysis uses an overlapping window. Count every newly seen
// accepted interval instead of incrementing only once per analysis pass;
// otherwise a 90 BPM pulse sampled every 0.5 s loses real beats.
let newlyAccepted = liveBeatTracker.ingest(acceptedIntervals)
for beat in newlyAccepted {
liveBeatSequence = beat.sequence
pendingLiveBeats.append(
NativeAcceptedBeat(
sequence: Double(beat.sequence),
timestampSeconds: beat.timestampSeconds,
intervalMs: beat.intervalMs,
confidence: beat.confidence
)
)
}
}
private func makeNativeResult(
analysis: PPGAnalysisResult,
snapshot: SessionSnapshot
) -> NativePPGProcessingResult {
var issues = analysis.quality.issues.map(Self.nativeIssue)
if analysis.bpm.rejectedPeakCount > max(3, analysis.bpm.validPeakCount / 4) {
Self.appendUnique("tooManyRejectedPeaks", to: &issues)
}
let observedDroppedRatio = snapshot.samples.isEmpty
? 0
: Double(snapshot.acquisitionDroppedFrameCount)
/ Double(snapshot.samples.count + snapshot.acquisitionDroppedFrameCount)
let droppedFrameRatio = min(
max(max(analysis.quality.droppedFrameRatio, observedDroppedRatio), 0),
1
)
if droppedFrameRatio > 0.12 {
Self.appendUnique("droppedFrames", to: &issues)
}
let timestampJitterRatio = Self.timestampJitterRatio(analysis.timebase)
let longestGapSeconds = max(
analysis.timebase.maximumGapSeconds,
snapshot.maximumAcquisitionGapSeconds
)
let isValid = analysis.status == .valid
let invalidReason = isValid ? nil : Self.invalidReason(for: issues)
let resampledSampleCount = max(
Int((analysis.timebase.durationSeconds * snapshot.configuration.targetSampleRate).rounded(.down)) + 1,
0
)
return NativePPGProcessingResult(
valid: isValid,
invalidReason: invalidReason,
bpm: NativeBPMEstimate(
bpm: analysis.bpm.bpm,
temporalBpm: analysis.bpm.temporalBpm,
frequencyBpm: analysis.bpm.frequencyBpm,
methodAgreementBpm: analysis.bpm.methodAgreementBpm,
validPeakCount: Double(analysis.bpm.validPeakCount),
rejectedPeakCount: Double(analysis.bpm.rejectedPeakCount),
confidence: analysis.bpm.confidence,
selectedChannel: analysis.bpm.selectedChannel.rawValue
),
prv: NativePRVMetrics(
estimatedRmssdMs: analysis.prv.estimatedRmssdMs,
estimatedSdnnMs: analysis.prv.estimatedSdnnMs,
meanIpiMs: analysis.prv.meanIpiMs,
medianIpiMs: analysis.prv.medianIpiMs,
pnn50Percent: analysis.prv.pnn50Percent,
rangeIpiMs: analysis.prv.rangeIpiMs,
modeIpiMs: analysis.prv.modeIpiMs,
amo50Percent: analysis.prv.amo50Percent,
coefficientOfVariation: analysis.prv.coefficientOfVariation,
validIntervalCount: Double(analysis.prv.validIntervalCount),
rejectedIntervalCount: Double(analysis.prv.rejectedIntervalCount),
rejectedIntervalRatio: analysis.prv.rejectedIntervalRatio,
reliable: analysis.prv.reliable
),
quality: NativeSignalQualityResult(
score: analysis.quality.score,
level: analysis.quality.level.rawValue,
usableDurationSeconds: analysis.quality.usableDurationSeconds,
droppedFrameRatio: droppedFrameRatio,
saturationRatio: analysis.quality.saturationRatio,
rejectedIntervalRatio: analysis.quality.rejectedIntervalRatio,
signalToNoiseRatio: analysis.quality.signalToNoiseRatio,
motionArtifactRatio: analysis.quality.motionArtifactRatio,
methodAgreementScore: analysis.quality.methodAgreementScore,
timestampJitterRatio: timestampJitterRatio,
longestGapSeconds: longestGapSeconds,
issues: issues
),
startedAtMonotonicSeconds: snapshot.startedAtMonotonicSeconds,
endedAtMonotonicSeconds: snapshot.endedAtMonotonicSeconds,
rawSampleCount: Double(snapshot.samples.count),
resampledSampleCount: Double(resampledSampleCount)
)
}
private func makeInvalidResult(
snapshot: SessionSnapshot,
reason: String,
issue: String
) -> NativePPGProcessingResult {
let intervals = zip(snapshot.samples, snapshot.samples.dropFirst()).compactMap { pair -> Double? in
let interval = pair.1.timestampSeconds - pair.0.timestampSeconds
return interval.isFinite && interval > 0 ? interval : nil
}
let estimatedRate = intervals.isEmpty ? 0 : 1 / max(SignalStatistics.median(intervals), 1e-9)
let intervalMAD = intervals.isEmpty ? 0 : SignalStatistics.mad(intervals)
let jitterRatio = estimatedRate > 0 ? intervalMAD * estimatedRate : 0
let saturation = snapshot.samples.isEmpty
? 0
: SignalStatistics.mean(snapshot.samples.map(\.saturationRatio))
let motion = snapshot.samples.isEmpty
? 0
: SignalStatistics.mean(snapshot.samples.map(\.motionArtifactScore))
let droppedRatio = snapshot.samples.isEmpty
? 0
: Double(snapshot.acquisitionDroppedFrameCount)
/ Double(snapshot.samples.count + snapshot.acquisitionDroppedFrameCount)
return NativePPGProcessingResult(
valid: false,
invalidReason: reason,
bpm: NativeBPMEstimate(
bpm: nil,
temporalBpm: nil,
frequencyBpm: nil,
methodAgreementBpm: nil,
validPeakCount: 0,
rejectedPeakCount: 0,
confidence: 0,
selectedChannel: "none"
),
prv: NativePRVMetrics(
estimatedRmssdMs: nil,
estimatedSdnnMs: nil,
meanIpiMs: nil,
medianIpiMs: nil,
pnn50Percent: nil,
rangeIpiMs: nil,
modeIpiMs: nil,
amo50Percent: nil,
coefficientOfVariation: nil,
validIntervalCount: 0,
rejectedIntervalCount: 0,
rejectedIntervalRatio: snapshot.samples.isEmpty ? 1 : 0,
reliable: false
),
quality: NativeSignalQualityResult(
score: 0,
level: "invalid",
usableDurationSeconds: snapshot.usableDurationSeconds,
droppedFrameRatio: min(max(droppedRatio, 0), 1),
saturationRatio: min(max(saturation, 0), 1),
rejectedIntervalRatio: snapshot.samples.isEmpty ? 1 : 0,
signalToNoiseRatio: nil,
motionArtifactRatio: min(max(motion, 0), 1),
methodAgreementScore: 0,
timestampJitterRatio: max(jitterRatio, 0),
longestGapSeconds: snapshot.maximumAcquisitionGapSeconds,
issues: [issue]
),
startedAtMonotonicSeconds: snapshot.startedAtMonotonicSeconds,
endedAtMonotonicSeconds: snapshot.endedAtMonotonicSeconds,
rawSampleCount: Double(snapshot.samples.count),
resampledSampleCount: 0
)
}
private func resetAfterFinishing() {
stateLock.lock()
resetLocked()
stateLock.unlock()
}
private func resetLocked() {
contactDetector?.reset()
contactDetector = nil
configuration = nil
terminalThermalState = nil
numericSamples.removeAll(keepingCapacity: false)
phase = .idle
startedAtMonotonicSeconds = 0
terminalReason = nil
bufferLimitReached = false
processedFrameCount = 0
droppedFrameCount = 0
acquisitionDroppedFrameCount = 0
lastObservedTimestamp = nil
recentFrameIntervals.removeAll(keepingCapacity: true)
maximumAcquisitionGapSeconds = 0
validContactStartedAt = nil
acquisitionStartedAt = nil
lastUsableTimestamp = nil
usableDurationSeconds = 0
lastMetrics = nil
lastContactEvaluation = nil
liveBpm = nil
liveIpiMs = nil
liveBeatSequence = 0
liveBeatTracker.reset()
lastLiveAnalysisFrame = 0
pendingLiveBeats.removeAll(keepingCapacity: false)
liveWindowQuality = nil
totalCollectedSampleCount = 0
}
private static func isUnsafe(_ state: ThermalState) -> Bool {
PPGThermalSafetyPolicy.shouldStopMeasurement(forStateName: state.stringValue)
}
private static func finiteTimestamp(_ timestamp: Double, fallback: Double?) -> Double {
if timestamp.isFinite, timestamp >= 0 { return timestamp }
return fallback ?? 0
}
private static func timestampJitterRatio(_ diagnostics: TimebaseDiagnostics) -> Double {
guard diagnostics.estimatedSampleRateHz > 0 else { return 0 }
return max(diagnostics.frameIntervalMADSeconds * diagnostics.estimatedSampleRateHz, 0)
}
private static func nativeIssue(_ issue: SignalQualityIssue) -> String {
switch issue {
case .insufficientDuration:
return "durationInsufficient"
case .invalidTimestamps, .irregularTimestamps:
return "timestampJitter"
case .longInterruption:
return "longGap"
case .tooManyDroppedFrames:
return "droppedFrames"
case .poorFingerContact:
return "contactInsufficient"
case .excessiveSaturation:
return "saturation"
case .motionArtifacts:
return "motionArtifacts"
case .lowPulsatileAmplitude:
return "lowPulsatileAmplitude"
case .lowSignalToNoise:
return "lowSignalToNoiseRatio"
case .tooFewPeaks:
return "tooFewPeaks"
case .inconsistentBPMMethods:
return "methodDisagreement"
case .tooManyRejectedIntervals:
return "tooManyRejectedIntervals"
case .unstableChannel:
return "channelInstability"
case .nonFiniteInput:
return "nonFiniteProcessingResult"
}
}
private static func appendUnique(_ issue: String, to issues: inout [String]) {
if !issues.contains(issue) { issues.append(issue) }
}
private static func invalidReason(for issues: [String]) -> String {
guard let issue = issues.first else {
return "La qualité du signal est insuffisante."
}
switch issue {
case "contactInsufficient", "contactInterrupted":
return "Le contact du doigt a été insuffisant ou interrompu."
case "durationInsufficient":
return "La durée de signal exploitable est insuffisante."
case "droppedFrames", "timestampJitter", "longGap":
return "La capture comporte trop d’interruptions ou d’images manquantes."
case "saturation":
return "Le signal caméra est trop saturé."
case "motionArtifacts":
return "Trop de mouvements ont perturbé la mesure."
case "lowPulsatileAmplitude", "lowSignalToNoiseRatio":
return "Le signal pulsatile est trop faible ou trop bruité."
case "tooFewPeaks", "tooManyRejectedPeaks", "tooManyRejectedIntervals":
return "Trop peu de pulsations fiables ont été détectées."
case "methodDisagreement":
return "Les estimations temporelle et fréquentielle ne concordent pas."
case "channelInstability":
return "Aucun canal couleur n’est suffisamment stable."
default:
return "Le traitement numérique du signal a produit un résultat invalide."
}
}
private static func mapProcessingError(_ error: Error) -> (reason: String, issue: String) {
guard let signalError = error as? PPGSignalError else {
return ("Le traitement numérique du signal a échoué.", "nonFiniteProcessingResult")
}
switch signalError {
case .insufficientSamples, .insufficientDuration:
return ("La durée de signal exploitable est insuffisante.", "durationInsufficient")
case .invalidTimestamps:
return ("Les horodatages caméra sont invalides.", "timestampJitter")
case .noUsableChannel:
return ("Aucun canal couleur n’est exploitable.", "channelInstability")
case .invalidConfiguration:
return ("La configuration du traitement du signal est invalide.", "nonFiniteProcessingResult")
}
}
}
private enum HaptikaPPGFrameProcessorError: LocalizedError {
case invalidConfiguration(String)
case unsafeThermalState(String)
case frameUnavailable
case measurementNotStarted
var errorDescription: String? {
switch self {
case .invalidConfiguration(let reason):
return "Invalid PPG configuration: \(reason)."
case .unsafeThermalState(let state):
return "PPG measurement refused because the iPhone thermal state is \(state)."
case .frameUnavailable:
return "The VisionCamera frame is invalid or does not expose a CMSampleBuffer."
case .measurementNotStarted:
return "No PPG measurement is currently active."
}
}
}