feat: implement Haptika PPG and Watch V1
This commit is contained in:
+887
@@ -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."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
import Foundation
|
||||
|
||||
struct PPGContactEvaluation: Sendable {
|
||||
let state: FingerContactState
|
||||
let confidence: Double
|
||||
let pulsatileAmplitude: Double
|
||||
let motionArtifactScore: Double
|
||||
}
|
||||
|
||||
/// Camera-agnostic coverage detector.
|
||||
///
|
||||
/// Contact is deliberately not a prerequisite for collecting frames. This
|
||||
/// class only coaches the user and supplies soft quality metadata. The final
|
||||
/// decision is made from the periodic optical signal, never from skin colour.
|
||||
final class PPGFingerContactDetector {
|
||||
private let requiredStableFrames = 6
|
||||
private let requiredReleaseFrames = 12
|
||||
private let historyLimit = 90
|
||||
private var history: [PPGFrameColorMetrics] = []
|
||||
private var state: FingerContactState = .adjusting
|
||||
private var stableFrames = 0
|
||||
private var releaseFrames = 0
|
||||
|
||||
init(minimumConfidence _: Double) {
|
||||
history.reserveCapacity(historyLimit)
|
||||
}
|
||||
|
||||
func reset() {
|
||||
history.removeAll(keepingCapacity: true)
|
||||
state = .adjusting
|
||||
stableFrames = 0
|
||||
releaseFrames = 0
|
||||
}
|
||||
|
||||
func evaluate(_ metrics: PPGFrameColorMetrics) -> PPGContactEvaluation {
|
||||
let previous = history.last
|
||||
history.append(metrics)
|
||||
if history.count > historyLimit {
|
||||
history.removeFirst(history.count - historyLimit)
|
||||
}
|
||||
|
||||
let brightness = metrics.luminanceMean / 255
|
||||
let red = metrics.redMean / 255
|
||||
let green = metrics.greenMean / 255
|
||||
let blue = metrics.blueMean / 255
|
||||
let chroma = max(red, green, blue) - min(red, green, blue)
|
||||
let spatialDeviation = sqrt(max(metrics.redVariance + metrics.greenVariance, 0) / 2)
|
||||
let uniformity = clamp01(1 - spatialDeviation / 82)
|
||||
let exposure = smoothWindow(brightness, lower: 0.025, upper: 0.985, feather: 0.10)
|
||||
let warmth = clamp01(0.5 + (red - blue) * 1.4)
|
||||
let coverage = clamp01(0.45 * uniformity + 0.30 * exposure + 0.15 * warmth + 0.10 * min(chroma * 3, 1))
|
||||
let motion = motionScore(current: metrics, previous: previous)
|
||||
let amplitude = pulsatileAmplitude()
|
||||
let temporalStability = clamp01(1 - motion / 0.55)
|
||||
let confidence = clamp01(0.72 * coverage + 0.28 * temporalStability)
|
||||
|
||||
let candidate: FingerContactState
|
||||
if brightness < 0.015 || (uniformity < 0.12 && warmth < 0.25) {
|
||||
candidate = .absent
|
||||
} else if brightness > 0.992 || metrics.saturationRatio > 0.92 {
|
||||
candidate = .overexposed
|
||||
} else if history.count >= 15 && motion > 0.68 {
|
||||
candidate = .unstable
|
||||
} else if confidence >= 0.48 {
|
||||
candidate = .valid
|
||||
} else {
|
||||
candidate = .adjusting
|
||||
}
|
||||
|
||||
applyHysteresis(candidate)
|
||||
return PPGContactEvaluation(
|
||||
state: state,
|
||||
confidence: confidence,
|
||||
pulsatileAmplitude: amplitude,
|
||||
motionArtifactScore: motion
|
||||
)
|
||||
}
|
||||
|
||||
private func applyHysteresis(_ candidate: FingerContactState) {
|
||||
if candidate == .valid {
|
||||
stableFrames += 1
|
||||
releaseFrames = 0
|
||||
if stableFrames >= requiredStableFrames { state = .valid }
|
||||
return
|
||||
}
|
||||
|
||||
stableFrames = 0
|
||||
releaseFrames += 1
|
||||
// Brief changes are feedback, not session resets. Holding the last valid
|
||||
// state avoids a flashing UI while automatic exposure settles.
|
||||
if state == .valid && releaseFrames < requiredReleaseFrames { return }
|
||||
state = candidate
|
||||
}
|
||||
|
||||
private func pulsatileAmplitude() -> Double {
|
||||
let window = history.suffix(60)
|
||||
guard window.count >= 20 else { return 0 }
|
||||
let normalized = window.map {
|
||||
$0.greenMean / max($0.redMean + $0.greenMean + $0.blueMean, 1)
|
||||
}
|
||||
return max((normalized.max() ?? 0) - (normalized.min() ?? 0), 0)
|
||||
}
|
||||
|
||||
private func motionScore(
|
||||
current: PPGFrameColorMetrics,
|
||||
previous: PPGFrameColorMetrics?
|
||||
) -> Double {
|
||||
guard let previous else { return 0 }
|
||||
let lightDelta = abs(current.luminanceMean - previous.luminanceMean) / 255
|
||||
let redDelta = abs(current.redMean - previous.redMean) / 255
|
||||
let greenDelta = abs(current.greenMean - previous.greenMean) / 255
|
||||
// Normal pulse modulation is small. Only large frame-to-frame steps are
|
||||
// considered motion, regardless of the absolute colour of the fingertip.
|
||||
return clamp01(max(lightDelta / 0.16, (redDelta + greenDelta) / 0.28))
|
||||
}
|
||||
|
||||
private func smoothWindow(
|
||||
_ value: Double,
|
||||
lower: Double,
|
||||
upper: Double,
|
||||
feather: Double
|
||||
) -> Double {
|
||||
if value <= lower || value >= upper { return 0 }
|
||||
if value < lower + feather { return clamp01((value - lower) / feather) }
|
||||
if value > upper - feather { return clamp01((upper - value) / feather) }
|
||||
return 1
|
||||
}
|
||||
|
||||
private func clamp01(_ value: Double) -> Double {
|
||||
min(max(value.isFinite ? value : 0, 0), 1)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,237 @@
|
||||
import CoreMedia
|
||||
import CoreVideo
|
||||
import Foundation
|
||||
|
||||
struct PPGFrameColorMetrics: Sendable {
|
||||
let timestampSeconds: Double
|
||||
let redMean: Double
|
||||
let greenMean: Double
|
||||
let blueMean: Double
|
||||
let redVariance: Double
|
||||
let greenVariance: Double
|
||||
let luminanceMean: Double
|
||||
let saturationRatio: Double
|
||||
}
|
||||
|
||||
enum PPGFrameMetricsExtractorError: Error {
|
||||
case invalidTimestamp
|
||||
case unsupportedPixelFormat(OSType)
|
||||
case pixelBufferLockFailed(CVReturn)
|
||||
case missingPlane
|
||||
case missingBaseAddress
|
||||
case emptyRegionOfInterest
|
||||
}
|
||||
|
||||
/// Extracts compact color statistics from a central ROI without retaining or copying a frame.
|
||||
final class PPGFrameMetricsExtractor {
|
||||
private let gridSize: Int
|
||||
private let roiFraction: Double
|
||||
|
||||
init(gridSize: Int = 32, roiFraction: Double = 0.55) {
|
||||
self.gridSize = max(8, gridSize)
|
||||
self.roiFraction = min(max(roiFraction, 0.2), 0.9)
|
||||
}
|
||||
|
||||
func extract(from sampleBuffer: CMSampleBuffer) throws -> PPGFrameColorMetrics {
|
||||
let timestamp = CMTimeGetSeconds(CMSampleBufferGetPresentationTimeStamp(sampleBuffer))
|
||||
guard timestamp.isFinite, timestamp >= 0 else {
|
||||
throw PPGFrameMetricsExtractorError.invalidTimestamp
|
||||
}
|
||||
guard let pixelBuffer = CMSampleBufferGetImageBuffer(sampleBuffer) else {
|
||||
throw PPGFrameMetricsExtractorError.missingBaseAddress
|
||||
}
|
||||
|
||||
let lockFlags = CVPixelBufferLockFlags.readOnly
|
||||
let lockStatus = CVPixelBufferLockBaseAddress(pixelBuffer, lockFlags)
|
||||
guard lockStatus == kCVReturnSuccess else {
|
||||
throw PPGFrameMetricsExtractorError.pixelBufferLockFailed(lockStatus)
|
||||
}
|
||||
defer { CVPixelBufferUnlockBaseAddress(pixelBuffer, lockFlags) }
|
||||
|
||||
let pixelFormat = CVPixelBufferGetPixelFormatType(pixelBuffer)
|
||||
switch pixelFormat {
|
||||
case kCVPixelFormatType_420YpCbCr8BiPlanarFullRange:
|
||||
return try extractBiPlanarYUV(
|
||||
from: pixelBuffer,
|
||||
timestamp: timestamp,
|
||||
isFullRange: true
|
||||
)
|
||||
case kCVPixelFormatType_420YpCbCr8BiPlanarVideoRange:
|
||||
return try extractBiPlanarYUV(
|
||||
from: pixelBuffer,
|
||||
timestamp: timestamp,
|
||||
isFullRange: false
|
||||
)
|
||||
case kCVPixelFormatType_32BGRA:
|
||||
return try extractBGRA(from: pixelBuffer, timestamp: timestamp)
|
||||
default:
|
||||
throw PPGFrameMetricsExtractorError.unsupportedPixelFormat(pixelFormat)
|
||||
}
|
||||
}
|
||||
|
||||
private func extractBiPlanarYUV(
|
||||
from pixelBuffer: CVPixelBuffer,
|
||||
timestamp: Double,
|
||||
isFullRange: Bool
|
||||
) throws -> PPGFrameColorMetrics {
|
||||
guard CVPixelBufferGetPlaneCount(pixelBuffer) >= 2 else {
|
||||
throw PPGFrameMetricsExtractorError.missingPlane
|
||||
}
|
||||
guard let yBase = CVPixelBufferGetBaseAddressOfPlane(pixelBuffer, 0),
|
||||
let uvBase = CVPixelBufferGetBaseAddressOfPlane(pixelBuffer, 1)
|
||||
else {
|
||||
throw PPGFrameMetricsExtractorError.missingBaseAddress
|
||||
}
|
||||
|
||||
let width = CVPixelBufferGetWidthOfPlane(pixelBuffer, 0)
|
||||
let height = CVPixelBufferGetHeightOfPlane(pixelBuffer, 0)
|
||||
let yStride = CVPixelBufferGetBytesPerRowOfPlane(pixelBuffer, 0)
|
||||
let uvStride = CVPixelBufferGetBytesPerRowOfPlane(pixelBuffer, 1)
|
||||
let yBytes = yBase.assumingMemoryBound(to: UInt8.self)
|
||||
let uvBytes = uvBase.assumingMemoryBound(to: UInt8.self)
|
||||
let roi = regionOfInterest(width: width, height: height)
|
||||
let stepX = max(roi.width / gridSize, 1)
|
||||
let stepY = max(roi.height / gridSize, 1)
|
||||
var accumulator = ColorAccumulator()
|
||||
|
||||
for y in stride(from: roi.minY, to: roi.maxY, by: stepY) {
|
||||
for x in stride(from: roi.minX, to: roi.maxX, by: stepX) {
|
||||
let yValue = Double(yBytes[y * yStride + x])
|
||||
let uvOffset = (y / 2) * uvStride + (x / 2) * 2
|
||||
let cb = Double(uvBytes[uvOffset])
|
||||
let cr = Double(uvBytes[uvOffset + 1])
|
||||
let rgb = convertYCbCrToRGB(y: yValue, cb: cb, cr: cr, isFullRange: isFullRange)
|
||||
accumulator.add(red: rgb.red, green: rgb.green, blue: rgb.blue, luminance: yValue)
|
||||
}
|
||||
}
|
||||
|
||||
return try accumulator.result(timestamp: timestamp)
|
||||
}
|
||||
|
||||
private func extractBGRA(
|
||||
from pixelBuffer: CVPixelBuffer,
|
||||
timestamp: Double
|
||||
) throws -> PPGFrameColorMetrics {
|
||||
guard let baseAddress = CVPixelBufferGetBaseAddress(pixelBuffer) else {
|
||||
throw PPGFrameMetricsExtractorError.missingBaseAddress
|
||||
}
|
||||
let width = CVPixelBufferGetWidth(pixelBuffer)
|
||||
let height = CVPixelBufferGetHeight(pixelBuffer)
|
||||
let bytesPerRow = CVPixelBufferGetBytesPerRow(pixelBuffer)
|
||||
let bytes = baseAddress.assumingMemoryBound(to: UInt8.self)
|
||||
let roi = regionOfInterest(width: width, height: height)
|
||||
let stepX = max(roi.width / gridSize, 1)
|
||||
let stepY = max(roi.height / gridSize, 1)
|
||||
var accumulator = ColorAccumulator()
|
||||
|
||||
for y in stride(from: roi.minY, to: roi.maxY, by: stepY) {
|
||||
for x in stride(from: roi.minX, to: roi.maxX, by: stepX) {
|
||||
let offset = y * bytesPerRow + x * 4
|
||||
let blue = Double(bytes[offset])
|
||||
let green = Double(bytes[offset + 1])
|
||||
let red = Double(bytes[offset + 2])
|
||||
let luminance = 0.2126 * red + 0.7152 * green + 0.0722 * blue
|
||||
accumulator.add(red: red, green: green, blue: blue, luminance: luminance)
|
||||
}
|
||||
}
|
||||
|
||||
return try accumulator.result(timestamp: timestamp)
|
||||
}
|
||||
|
||||
private func regionOfInterest(width: Int, height: Int) -> (
|
||||
minX: Int,
|
||||
maxX: Int,
|
||||
minY: Int,
|
||||
maxY: Int,
|
||||
width: Int,
|
||||
height: Int
|
||||
) {
|
||||
let roiWidth = max(Int(Double(width) * roiFraction), 1)
|
||||
let roiHeight = max(Int(Double(height) * roiFraction), 1)
|
||||
let minX = max((width - roiWidth) / 2, 0)
|
||||
let minY = max((height - roiHeight) / 2, 0)
|
||||
return (minX, minX + roiWidth, minY, minY + roiHeight, roiWidth, roiHeight)
|
||||
}
|
||||
|
||||
private func convertYCbCrToRGB(
|
||||
y: Double,
|
||||
cb: Double,
|
||||
cr: Double,
|
||||
isFullRange: Bool
|
||||
) -> (red: Double, green: Double, blue: Double) {
|
||||
let red: Double
|
||||
let green: Double
|
||||
let blue: Double
|
||||
|
||||
if isFullRange {
|
||||
let shiftedCb = cb - 128
|
||||
let shiftedCr = cr - 128
|
||||
red = y + 1.402 * shiftedCr
|
||||
green = y - 0.344_136 * shiftedCb - 0.714_136 * shiftedCr
|
||||
blue = y + 1.772 * shiftedCb
|
||||
} else {
|
||||
let shiftedY = max(y - 16, 0)
|
||||
let shiftedCb = cb - 128
|
||||
let shiftedCr = cr - 128
|
||||
red = 1.164_383 * shiftedY + 1.596_027 * shiftedCr
|
||||
green = 1.164_383 * shiftedY - 0.391_762 * shiftedCb - 0.812_968 * shiftedCr
|
||||
blue = 1.164_383 * shiftedY + 2.017_232 * shiftedCb
|
||||
}
|
||||
|
||||
return (clampByte(red), clampByte(green), clampByte(blue))
|
||||
}
|
||||
|
||||
private func clampByte(_ value: Double) -> Double {
|
||||
min(max(value, 0), 255)
|
||||
}
|
||||
}
|
||||
|
||||
private struct ColorAccumulator {
|
||||
private(set) var count = 0
|
||||
private var redMean = 0.0
|
||||
private var greenMean = 0.0
|
||||
private var blueMean = 0.0
|
||||
private var luminanceMean = 0.0
|
||||
private var redM2 = 0.0
|
||||
private var greenM2 = 0.0
|
||||
private var saturatedCount = 0
|
||||
|
||||
mutating func add(red: Double, green: Double, blue: Double, luminance: Double) {
|
||||
count += 1
|
||||
let n = Double(count)
|
||||
|
||||
let redDelta = red - redMean
|
||||
redMean += redDelta / n
|
||||
redM2 += redDelta * (red - redMean)
|
||||
|
||||
let greenDelta = green - greenMean
|
||||
greenMean += greenDelta / n
|
||||
greenM2 += greenDelta * (green - greenMean)
|
||||
|
||||
blueMean += (blue - blueMean) / n
|
||||
luminanceMean += (luminance - luminanceMean) / n
|
||||
// A fingertip lit by the torch commonly clips only the red channel while
|
||||
// green still contains a clean pulsatile waveform. Count true optical
|
||||
// clipping only when both useful color channels (or luminance) are clipped.
|
||||
if (red >= 250 && green >= 250) || luminance >= 250 {
|
||||
saturatedCount += 1
|
||||
}
|
||||
}
|
||||
|
||||
func result(timestamp: Double) throws -> PPGFrameColorMetrics {
|
||||
guard count > 0 else {
|
||||
throw PPGFrameMetricsExtractorError.emptyRegionOfInterest
|
||||
}
|
||||
let divisor = Double(max(count - 1, 1))
|
||||
return PPGFrameColorMetrics(
|
||||
timestampSeconds: timestamp,
|
||||
redMean: redMean,
|
||||
greenMean: greenMean,
|
||||
blueMean: blueMean,
|
||||
redVariance: redM2 / divisor,
|
||||
greenVariance: greenM2 / divisor,
|
||||
luminanceMean: luminanceMean,
|
||||
saturationRatio: Double(saturatedCount) / Double(count)
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,303 @@
|
||||
import CoreHaptics
|
||||
import Foundation
|
||||
import NitroModules
|
||||
|
||||
/// HAPTIKA.CARDIAC_MIRROR_BEAT v1.0.0
|
||||
///
|
||||
/// This pattern is deliberately isolated from generic notification haptics. A
|
||||
/// short transient with a controlled sharpness gives the user a repeatable
|
||||
/// “heartbeat” texture without pretending to reproduce a clinical waveform.
|
||||
final class HybridHaptikaCardiacMirror: HybridHaptikaCardiacMirrorSpec {
|
||||
private static let patternID = "HAPTIKA.CARDIAC_MIRROR_BEAT"
|
||||
private static let patternVersion = "1.0.0"
|
||||
// Kept at zero for Nitro ABI compatibility. Public Watch workout APIs expose
|
||||
// cadence updates, not exact beat events, so Haptika advertises no latency
|
||||
// SLA between a biological beat and the reconstructed iPhone pulse.
|
||||
private static let latencyBudgetMs = 0.0
|
||||
|
||||
private let lock = NSLock()
|
||||
private var engine: CHHapticEngine?
|
||||
private var patternPlayer: CHHapticPatternPlayer?
|
||||
private var regulationPlayer: CHHapticAdvancedPatternPlayer?
|
||||
private var supported: Bool
|
||||
|
||||
override init() {
|
||||
self.supported = CHHapticEngine.capabilitiesForHardware().supportsHaptics
|
||||
super.init()
|
||||
guard supported else { return }
|
||||
configureEngine()
|
||||
}
|
||||
|
||||
func isSupported() throws -> Bool {
|
||||
return supported
|
||||
}
|
||||
|
||||
func patternInfo() throws -> NativeCardiacMirrorPatternInfo {
|
||||
return NativeCardiacMirrorPatternInfo(
|
||||
id: Self.patternID,
|
||||
version: Self.patternVersion,
|
||||
latencyBudgetMs: Self.latencyBudgetMs
|
||||
)
|
||||
}
|
||||
|
||||
func start(targetBpm: Double) throws {
|
||||
guard supported else { return }
|
||||
lock.lock()
|
||||
defer { lock.unlock() }
|
||||
do {
|
||||
try engine?.start()
|
||||
if patternPlayer == nil {
|
||||
patternPlayer = try makePatternPlayer()
|
||||
}
|
||||
} catch {
|
||||
// Haptics are optional and must never block the visual experience.
|
||||
patternPlayer = nil
|
||||
}
|
||||
}
|
||||
|
||||
func pulse() throws {
|
||||
guard supported else { return }
|
||||
lock.lock()
|
||||
defer { lock.unlock() }
|
||||
do {
|
||||
try engine?.start()
|
||||
if patternPlayer == nil {
|
||||
patternPlayer = try makePatternPlayer()
|
||||
}
|
||||
try patternPlayer?.start(atTime: CHHapticTimeImmediate)
|
||||
} catch {
|
||||
patternPlayer = nil
|
||||
}
|
||||
}
|
||||
|
||||
func stop() throws {
|
||||
lock.lock()
|
||||
defer { lock.unlock() }
|
||||
patternPlayer = nil
|
||||
regulationPlayer = nil
|
||||
engine?.stop(completionHandler: nil)
|
||||
}
|
||||
|
||||
func playRegulationProtocol(kind: String) throws -> Double {
|
||||
guard supported else { return 0 }
|
||||
lock.lock()
|
||||
defer { lock.unlock() }
|
||||
|
||||
do {
|
||||
try engine?.start()
|
||||
try regulationPlayer?.stop(atTime: CHHapticTimeImmediate)
|
||||
|
||||
let configuration = try makeRegulationPattern(kind: kind)
|
||||
guard let engine else { return 0 }
|
||||
let player = try engine.makeAdvancedPlayer(with: configuration.pattern)
|
||||
player.loopEnabled = configuration.loops
|
||||
regulationPlayer = player
|
||||
try player.start(atTime: CHHapticTimeImmediate)
|
||||
return configuration.durationSeconds
|
||||
} catch {
|
||||
regulationPlayer = nil
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
func stopRegulationProtocol() throws {
|
||||
lock.lock()
|
||||
defer { lock.unlock() }
|
||||
try? regulationPlayer?.stop(atTime: CHHapticTimeImmediate)
|
||||
regulationPlayer = nil
|
||||
}
|
||||
|
||||
func startWatchSession() throws {
|
||||
HaptikaWatchConnectivityService.shared.activate()
|
||||
}
|
||||
|
||||
func watchReachable() throws -> Bool {
|
||||
return HaptikaWatchConnectivityService.shared.state().reachable
|
||||
}
|
||||
|
||||
func latestHeartbeat() throws -> NativeCardiacMirrorHeartbeat? {
|
||||
guard let sample = HaptikaWatchConnectivityService.shared.latestHeartRate() else {
|
||||
return nil
|
||||
}
|
||||
return NativeCardiacMirrorHeartbeat(
|
||||
timestampSeconds: sample.timestampSeconds,
|
||||
bpm: sample.bpm
|
||||
)
|
||||
}
|
||||
|
||||
private func configureEngine() {
|
||||
do {
|
||||
let hapticEngine = try CHHapticEngine()
|
||||
hapticEngine.resetHandler = { [weak self] in
|
||||
self?.lock.lock()
|
||||
defer { self?.lock.unlock() }
|
||||
self?.patternPlayer = nil
|
||||
}
|
||||
hapticEngine.stoppedHandler = { [weak self] _ in
|
||||
self?.lock.lock()
|
||||
defer { self?.lock.unlock() }
|
||||
self?.patternPlayer = nil
|
||||
}
|
||||
engine = hapticEngine
|
||||
} catch {
|
||||
supported = false
|
||||
}
|
||||
}
|
||||
|
||||
private func makePatternPlayer() throws -> CHHapticPatternPlayer {
|
||||
guard let engine else {
|
||||
throw NSError(domain: "HaptikaCardiacMirror", code: 1)
|
||||
}
|
||||
let intensity = CHHapticEventParameter(
|
||||
parameterID: .hapticIntensity,
|
||||
value: 0.78
|
||||
)
|
||||
let sharpness = CHHapticEventParameter(
|
||||
parameterID: .hapticSharpness,
|
||||
value: 0.32
|
||||
)
|
||||
let event = CHHapticEvent(
|
||||
eventType: .hapticTransient,
|
||||
parameters: [intensity, sharpness],
|
||||
relativeTime: 0
|
||||
)
|
||||
let pattern = try CHHapticPattern(events: [event], parameters: [])
|
||||
return try engine.makePlayer(with: pattern)
|
||||
}
|
||||
|
||||
private func makeRegulationPattern(
|
||||
kind: String
|
||||
) throws -> (pattern: CHHapticPattern, durationSeconds: Double, loops: Bool) {
|
||||
switch kind {
|
||||
case "coherence55":
|
||||
return (try makeBreathingPattern(inhale: 5, exhale: 5), 10, true)
|
||||
case "relax46":
|
||||
return (try makeBreathingPattern(inhale: 4, exhale: 6), 10, true)
|
||||
case "box4444":
|
||||
return (try makePhasedBreathingPattern(inhale: 4, hold: 4, exhale: 4, emptyHold: 4), 16, true)
|
||||
case "breathing478":
|
||||
return (try makePhasedBreathingPattern(inhale: 4, hold: 7, exhale: 8, emptyHold: 0), 19, true)
|
||||
case "sosStress":
|
||||
return (try makeSettlingPattern(intensity: 0.25, duration: 60), 62.2, false)
|
||||
case "microAnchor":
|
||||
return (try makeSettlingPattern(intensity: 0.15, duration: 35), 37, false)
|
||||
default:
|
||||
throw NSError(domain: "HaptikaRegulation", code: 2)
|
||||
}
|
||||
}
|
||||
|
||||
private func makeBreathingPattern(
|
||||
inhale: TimeInterval,
|
||||
exhale: TimeInterval
|
||||
) throws -> CHHapticPattern {
|
||||
let inhaleEvent = CHHapticEvent(
|
||||
eventType: .hapticContinuous,
|
||||
parameters: [
|
||||
CHHapticEventParameter(parameterID: .hapticIntensity, value: 0.55),
|
||||
CHHapticEventParameter(parameterID: .hapticSharpness, value: 0.08)
|
||||
],
|
||||
relativeTime: 0,
|
||||
duration: inhale
|
||||
)
|
||||
let transition = CHHapticEvent(
|
||||
eventType: .hapticTransient,
|
||||
parameters: [
|
||||
CHHapticEventParameter(parameterID: .hapticIntensity, value: 0.7),
|
||||
CHHapticEventParameter(parameterID: .hapticSharpness, value: 0.65)
|
||||
],
|
||||
relativeTime: inhale
|
||||
)
|
||||
let exhaleEvent = CHHapticEvent(
|
||||
eventType: .hapticContinuous,
|
||||
parameters: [
|
||||
CHHapticEventParameter(parameterID: .hapticIntensity, value: 0.24),
|
||||
CHHapticEventParameter(parameterID: .hapticSharpness, value: 0.04)
|
||||
],
|
||||
relativeTime: inhale + 0.08,
|
||||
duration: exhale - 0.08
|
||||
)
|
||||
return try CHHapticPattern(
|
||||
events: [inhaleEvent, transition, exhaleEvent],
|
||||
parameters: []
|
||||
)
|
||||
}
|
||||
|
||||
private func makePhasedBreathingPattern(
|
||||
inhale: TimeInterval,
|
||||
hold: TimeInterval,
|
||||
exhale: TimeInterval,
|
||||
emptyHold: TimeInterval
|
||||
) throws -> CHHapticPattern {
|
||||
let inhaleEvent = CHHapticEvent(
|
||||
eventType: .hapticContinuous,
|
||||
parameters: [
|
||||
CHHapticEventParameter(parameterID: .hapticIntensity, value: 0.48),
|
||||
CHHapticEventParameter(parameterID: .hapticSharpness, value: 0.08)
|
||||
],
|
||||
relativeTime: 0,
|
||||
duration: inhale
|
||||
)
|
||||
let fullHoldMarker = CHHapticEvent(
|
||||
eventType: .hapticTransient,
|
||||
parameters: [
|
||||
CHHapticEventParameter(parameterID: .hapticIntensity, value: 0.42),
|
||||
CHHapticEventParameter(parameterID: .hapticSharpness, value: 0.52)
|
||||
],
|
||||
relativeTime: inhale
|
||||
)
|
||||
let exhaleEvent = CHHapticEvent(
|
||||
eventType: .hapticContinuous,
|
||||
parameters: [
|
||||
CHHapticEventParameter(parameterID: .hapticIntensity, value: 0.20),
|
||||
CHHapticEventParameter(parameterID: .hapticSharpness, value: 0.03)
|
||||
],
|
||||
relativeTime: inhale + hold,
|
||||
duration: exhale
|
||||
)
|
||||
var events = [inhaleEvent, fullHoldMarker, exhaleEvent]
|
||||
if emptyHold > 0 {
|
||||
events.append(
|
||||
CHHapticEvent(
|
||||
eventType: .hapticTransient,
|
||||
parameters: [
|
||||
CHHapticEventParameter(parameterID: .hapticIntensity, value: 0.25),
|
||||
CHHapticEventParameter(parameterID: .hapticSharpness, value: 0.25)
|
||||
],
|
||||
relativeTime: inhale + hold + exhale + emptyHold - 0.02
|
||||
)
|
||||
)
|
||||
}
|
||||
return try CHHapticPattern(events: events, parameters: [])
|
||||
}
|
||||
|
||||
private func makeSettlingPattern(
|
||||
intensity: Float,
|
||||
duration: TimeInterval
|
||||
) throws -> CHHapticPattern {
|
||||
var events: [CHHapticEvent] = []
|
||||
for index in 0..<3 {
|
||||
events.append(
|
||||
CHHapticEvent(
|
||||
eventType: .hapticTransient,
|
||||
parameters: [
|
||||
CHHapticEventParameter(parameterID: .hapticIntensity, value: 0.62),
|
||||
CHHapticEventParameter(parameterID: .hapticSharpness, value: 0.42)
|
||||
],
|
||||
relativeTime: TimeInterval(index) * 0.6
|
||||
)
|
||||
)
|
||||
}
|
||||
events.append(
|
||||
CHHapticEvent(
|
||||
eventType: .hapticContinuous,
|
||||
parameters: [
|
||||
CHHapticEventParameter(parameterID: .hapticIntensity, value: intensity),
|
||||
CHHapticEventParameter(parameterID: .hapticSharpness, value: 0.04)
|
||||
],
|
||||
relativeTime: 2,
|
||||
duration: duration
|
||||
)
|
||||
)
|
||||
return try CHHapticPattern(events: events, parameters: [])
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,451 @@
|
||||
import Foundation
|
||||
import HealthKit
|
||||
import NitroModules
|
||||
|
||||
/// Read-only HealthKit access used as dated physiological context.
|
||||
///
|
||||
/// This module deliberately uses sample queries rather than observer/live queries. It never
|
||||
/// creates heartbeat series and never treats historical HealthKit values as a current reading.
|
||||
final class HybridHaptikaHealthKit: HybridHaptikaHealthKitSpec {
|
||||
private static let defaultLookbackDays = 30.0
|
||||
private static let minimumLookbackDays = 1.0
|
||||
private static let maximumLookbackDays = 365.0
|
||||
private static let maximumRecentHeartRateSamples = 100
|
||||
|
||||
private let healthStore: HKHealthStore
|
||||
|
||||
override init() {
|
||||
self.healthStore = HKHealthStore()
|
||||
super.init()
|
||||
}
|
||||
|
||||
func isAvailable() throws -> Bool {
|
||||
return HKHealthStore.isHealthDataAvailable()
|
||||
}
|
||||
|
||||
func requestReadAuthorization() throws -> Promise<Bool> {
|
||||
guard HKHealthStore.isHealthDataAvailable() else {
|
||||
return Promise.resolved(withResult: false)
|
||||
}
|
||||
|
||||
let healthStore = self.healthStore
|
||||
let readTypes = Self.readTypes()
|
||||
|
||||
return Promise<Bool>.async {
|
||||
do {
|
||||
try await healthStore.requestAuthorization(
|
||||
toShare: Set<HKSampleType>(),
|
||||
read: readTypes
|
||||
)
|
||||
|
||||
// HealthKit intentionally does not reveal whether read access was granted. A successful
|
||||
// authorization request only means that the sheet completed without an operational error.
|
||||
return true
|
||||
} catch {
|
||||
// HealthKit is optional; cancellation, a missing entitlement, or a locked/unavailable
|
||||
// store must not block the camera measurement flow.
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func fetchContext(lookbackDays: Double) throws -> Promise<NativeHealthKitContext> {
|
||||
let fetchedAt = Date()
|
||||
|
||||
guard HKHealthStore.isHealthDataAvailable() else {
|
||||
return Promise.resolved(
|
||||
withResult: Self.emptyContext(
|
||||
available: false,
|
||||
authorizationRequestSettled: false,
|
||||
fetchedAt: fetchedAt
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
let healthStore = self.healthStore
|
||||
let boundedLookbackDays = Self.boundedLookbackDays(lookbackDays)
|
||||
|
||||
return Promise<NativeHealthKitContext>.async {
|
||||
let authorizationRequestSettled = await Self.authorizationRequestIsSettled(
|
||||
healthStore: healthStore
|
||||
)
|
||||
|
||||
// Do not query before the optional permission flow has been presented. When access has been
|
||||
// denied, HealthKit protects the decision by returning no samples, just as it does when the
|
||||
// store genuinely has no samples.
|
||||
guard authorizationRequestSettled else {
|
||||
return Self.emptyContext(
|
||||
available: true,
|
||||
authorizationRequestSettled: false,
|
||||
fetchedAt: fetchedAt
|
||||
)
|
||||
}
|
||||
|
||||
let startDate = fetchedAt.addingTimeInterval(-boundedLookbackDays * 86_400.0)
|
||||
|
||||
async let restingSamples = Self.quantitySamplesSafely(
|
||||
healthStore: healthStore,
|
||||
type: Self.quantityType(.restingHeartRate),
|
||||
startDate: startDate,
|
||||
endDate: fetchedAt,
|
||||
limit: 1
|
||||
)
|
||||
async let hrvSamples = Self.quantitySamplesSafely(
|
||||
healthStore: healthStore,
|
||||
type: Self.quantityType(.heartRateVariabilitySDNN),
|
||||
startDate: startDate,
|
||||
endDate: fetchedAt,
|
||||
limit: 1
|
||||
)
|
||||
async let heartRateSamples = Self.quantitySamplesSafely(
|
||||
healthStore: healthStore,
|
||||
type: Self.quantityType(.heartRate),
|
||||
startDate: startDate,
|
||||
endDate: fetchedAt,
|
||||
limit: Self.maximumRecentHeartRateSamples
|
||||
)
|
||||
|
||||
let (resting, hrv, heartRates) = await (
|
||||
restingSamples,
|
||||
hrvSamples,
|
||||
heartRateSamples
|
||||
)
|
||||
|
||||
let latestResting = resting.first
|
||||
let latestHrv = hrv.first
|
||||
|
||||
let nativeResting = latestResting.flatMap {
|
||||
Self.makeNativeQuantitySample(
|
||||
$0,
|
||||
metricType: .restingheartrate,
|
||||
unit: Self.beatsPerMinuteUnit,
|
||||
unitLabel: "count/min"
|
||||
)
|
||||
}
|
||||
let nativeHrv = latestHrv.flatMap {
|
||||
Self.makeNativeQuantitySample(
|
||||
$0,
|
||||
metricType: .hrvsdnn,
|
||||
unit: Self.millisecondUnit,
|
||||
unitLabel: "ms"
|
||||
)
|
||||
}
|
||||
let nativeHeartRates = heartRates.compactMap {
|
||||
Self.makeNativeQuantitySample(
|
||||
$0,
|
||||
metricType: .heartrate,
|
||||
unit: Self.beatsPerMinuteUnit,
|
||||
unitLabel: "count/min"
|
||||
)
|
||||
}
|
||||
|
||||
return NativeHealthKitContext(
|
||||
available: true,
|
||||
// This means the authorization request no longer needs presenting. Apple deliberately
|
||||
// makes an explicit read denial indistinguishable from an authorized store with no data.
|
||||
authorized: authorizationRequestSettled,
|
||||
latestRestingHeartRate: nativeResting,
|
||||
latestHrvSdnn: nativeHrv,
|
||||
recentHeartRateSamples: nativeHeartRates,
|
||||
contextConfidence: Self.contextConfidence(
|
||||
latestResting: latestResting,
|
||||
latestHrv: latestHrv,
|
||||
heartRates: heartRates,
|
||||
fetchedAt: fetchedAt,
|
||||
lookbackDays: boundedLookbackDays
|
||||
),
|
||||
fetchedAt: Self.iso8601String(from: fetchedAt)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func fetchLatestHeartbeatSeries() throws -> Promise<NativeHeartbeatSeriesSummary?> {
|
||||
guard HKHealthStore.isHealthDataAvailable() else {
|
||||
return Promise.resolved(withResult: nil)
|
||||
}
|
||||
|
||||
let healthStore = self.healthStore
|
||||
|
||||
return Promise<NativeHeartbeatSeriesSummary?>.async {
|
||||
guard await Self.authorizationRequestIsSettled(healthStore: healthStore) else {
|
||||
return nil
|
||||
}
|
||||
|
||||
do {
|
||||
guard let sample = try await Self.latestHeartbeatSeriesSample(
|
||||
healthStore: healthStore
|
||||
) else {
|
||||
return nil
|
||||
}
|
||||
|
||||
var heartbeatTimes: [Double] = []
|
||||
var precededByGapIndexes: [Double] = []
|
||||
let descriptor = HKHeartbeatSeriesQueryDescriptor(sample)
|
||||
|
||||
for try await heartbeat in descriptor.results(for: healthStore) {
|
||||
let time = heartbeat.timeIntervalSinceStart
|
||||
guard time.isFinite, time >= 0 else {
|
||||
continue
|
||||
}
|
||||
|
||||
heartbeatTimes.append(time)
|
||||
if heartbeat.precededByGap {
|
||||
precededByGapIndexes.append(Double(heartbeatTimes.count - 1))
|
||||
}
|
||||
}
|
||||
|
||||
guard !heartbeatTimes.isEmpty else {
|
||||
return nil
|
||||
}
|
||||
|
||||
let source = sample.sourceRevision.source
|
||||
return NativeHeartbeatSeriesSummary(
|
||||
startDate: Self.iso8601String(from: sample.startDate),
|
||||
endDate: Self.iso8601String(from: sample.endDate),
|
||||
sourceName: Self.nonEmpty(source.name),
|
||||
sourceBundleIdentifier: Self.nonEmpty(source.bundleIdentifier),
|
||||
heartbeatTimesSinceSeriesStartSeconds: heartbeatTimes,
|
||||
precededByGapIndexes: precededByGapIndexes
|
||||
)
|
||||
} catch {
|
||||
// A read denial, no accessible series, protected-data lock, or query failure all degrade to
|
||||
// an absent optional context. None of them should affect the camera PPG measurement.
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static var beatsPerMinuteUnit: HKUnit {
|
||||
return HKUnit.count().unitDivided(by: .minute())
|
||||
}
|
||||
|
||||
private static var millisecondUnit: HKUnit {
|
||||
return HKUnit.secondUnit(with: .milli)
|
||||
}
|
||||
|
||||
private static func quantityType(
|
||||
_ identifier: HKQuantityTypeIdentifier
|
||||
) -> HKQuantityType? {
|
||||
return HKObjectType.quantityType(forIdentifier: identifier)
|
||||
}
|
||||
|
||||
private static func readTypes() -> Set<HKObjectType> {
|
||||
var types = Set<HKObjectType>()
|
||||
|
||||
if let heartRate = quantityType(.heartRate) {
|
||||
types.insert(heartRate)
|
||||
}
|
||||
if let restingHeartRate = quantityType(.restingHeartRate) {
|
||||
types.insert(restingHeartRate)
|
||||
}
|
||||
if let hrvSdnn = quantityType(.heartRateVariabilitySDNN) {
|
||||
types.insert(hrvSdnn)
|
||||
}
|
||||
|
||||
// Read-only access to series already present in HealthKit. No builder or write type is used.
|
||||
types.insert(HKSeriesType.heartbeat())
|
||||
return types
|
||||
}
|
||||
|
||||
private static func authorizationRequestIsSettled(
|
||||
healthStore: HKHealthStore
|
||||
) async -> Bool {
|
||||
do {
|
||||
let status = try await healthStore.statusForAuthorizationRequest(
|
||||
toShare: Set<HKSampleType>(),
|
||||
read: readTypes()
|
||||
)
|
||||
return status == .unnecessary
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
private static func quantitySamplesSafely(
|
||||
healthStore: HKHealthStore,
|
||||
type: HKQuantityType?,
|
||||
startDate: Date,
|
||||
endDate: Date,
|
||||
limit: Int
|
||||
) async -> [HKQuantitySample] {
|
||||
guard let type else {
|
||||
return []
|
||||
}
|
||||
|
||||
do {
|
||||
return try await quantitySamples(
|
||||
healthStore: healthStore,
|
||||
type: type,
|
||||
startDate: startDate,
|
||||
endDate: endDate,
|
||||
limit: limit
|
||||
)
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
private static func quantitySamples(
|
||||
healthStore: HKHealthStore,
|
||||
type: HKQuantityType,
|
||||
startDate: Date,
|
||||
endDate: Date,
|
||||
limit: Int
|
||||
) async throws -> [HKQuantitySample] {
|
||||
let predicate = HKQuery.predicateForSamples(
|
||||
withStart: startDate,
|
||||
end: endDate,
|
||||
options: [.strictEndDate]
|
||||
)
|
||||
let sort = NSSortDescriptor(
|
||||
key: HKSampleSortIdentifierEndDate,
|
||||
ascending: false
|
||||
)
|
||||
|
||||
return try await withCheckedThrowingContinuation { continuation in
|
||||
let query = HKSampleQuery(
|
||||
sampleType: type,
|
||||
predicate: predicate,
|
||||
limit: limit,
|
||||
sortDescriptors: [sort]
|
||||
) { _, samples, error in
|
||||
if let error {
|
||||
continuation.resume(throwing: error)
|
||||
return
|
||||
}
|
||||
|
||||
continuation.resume(
|
||||
returning: samples?.compactMap { $0 as? HKQuantitySample } ?? []
|
||||
)
|
||||
}
|
||||
|
||||
healthStore.execute(query)
|
||||
}
|
||||
}
|
||||
|
||||
private static func latestHeartbeatSeriesSample(
|
||||
healthStore: HKHealthStore
|
||||
) async throws -> HKHeartbeatSeriesSample? {
|
||||
let sort = NSSortDescriptor(
|
||||
key: HKSampleSortIdentifierEndDate,
|
||||
ascending: false
|
||||
)
|
||||
|
||||
return try await withCheckedThrowingContinuation { continuation in
|
||||
let query = HKSampleQuery(
|
||||
sampleType: HKSeriesType.heartbeat(),
|
||||
predicate: nil,
|
||||
limit: 1,
|
||||
sortDescriptors: [sort]
|
||||
) { _, samples, error in
|
||||
if let error {
|
||||
continuation.resume(throwing: error)
|
||||
return
|
||||
}
|
||||
|
||||
continuation.resume(
|
||||
returning: samples?.first as? HKHeartbeatSeriesSample
|
||||
)
|
||||
}
|
||||
|
||||
healthStore.execute(query)
|
||||
}
|
||||
}
|
||||
|
||||
private static func makeNativeQuantitySample(
|
||||
_ sample: HKQuantitySample,
|
||||
metricType: HealthMetricType,
|
||||
unit: HKUnit,
|
||||
unitLabel: String
|
||||
) -> NativeHealthMetricSample? {
|
||||
let value = sample.quantity.doubleValue(for: unit)
|
||||
guard value.isFinite else {
|
||||
return nil
|
||||
}
|
||||
|
||||
let source = sample.sourceRevision.source
|
||||
let deviceName = nonEmpty(sample.device?.name) ?? nonEmpty(sample.device?.model)
|
||||
|
||||
return NativeHealthMetricSample(
|
||||
type: metricType,
|
||||
value: value,
|
||||
unit: unitLabel,
|
||||
startDate: iso8601String(from: sample.startDate),
|
||||
endDate: iso8601String(from: sample.endDate),
|
||||
sourceName: nonEmpty(source.name),
|
||||
sourceBundleIdentifier: nonEmpty(source.bundleIdentifier),
|
||||
deviceName: deviceName
|
||||
)
|
||||
}
|
||||
|
||||
/// A conservative availability-and-recency score for optional context only. It is not a signal
|
||||
/// quality score and must not be interpreted as confidence in a stress or medical conclusion.
|
||||
private static func contextConfidence(
|
||||
latestResting: HKQuantitySample?,
|
||||
latestHrv: HKQuantitySample?,
|
||||
heartRates: [HKQuantitySample],
|
||||
fetchedAt: Date,
|
||||
lookbackDays: Double
|
||||
) -> Double {
|
||||
let horizon = max(lookbackDays * 86_400.0, 1.0)
|
||||
|
||||
func freshness(of sample: HKSample) -> Double {
|
||||
let age = max(fetchedAt.timeIntervalSince(sample.endDate), 0.0)
|
||||
return min(max(1.0 - age / horizon, 0.0), 1.0)
|
||||
}
|
||||
|
||||
var score = 0.0
|
||||
if let latestResting {
|
||||
score += 0.35 * freshness(of: latestResting)
|
||||
}
|
||||
if let latestHrv {
|
||||
score += 0.35 * freshness(of: latestHrv)
|
||||
}
|
||||
if let latestHeartRate = heartRates.first {
|
||||
let sampleCoverage = min(Double(heartRates.count) / 5.0, 1.0)
|
||||
score += 0.20 * freshness(of: latestHeartRate)
|
||||
score += 0.10 * sampleCoverage
|
||||
}
|
||||
|
||||
return min(max(score, 0.0), 1.0)
|
||||
}
|
||||
|
||||
private static func emptyContext(
|
||||
available: Bool,
|
||||
authorizationRequestSettled: Bool,
|
||||
fetchedAt: Date
|
||||
) -> NativeHealthKitContext {
|
||||
return NativeHealthKitContext(
|
||||
available: available,
|
||||
authorized: authorizationRequestSettled,
|
||||
latestRestingHeartRate: nil,
|
||||
latestHrvSdnn: nil,
|
||||
recentHeartRateSamples: [],
|
||||
contextConfidence: 0,
|
||||
fetchedAt: iso8601String(from: fetchedAt)
|
||||
)
|
||||
}
|
||||
|
||||
private static func boundedLookbackDays(_ value: Double) -> Double {
|
||||
guard value.isFinite else {
|
||||
return defaultLookbackDays
|
||||
}
|
||||
return min(max(value, minimumLookbackDays), maximumLookbackDays)
|
||||
}
|
||||
|
||||
private static func iso8601String(from date: Date) -> String {
|
||||
let formatter = ISO8601DateFormatter()
|
||||
formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
|
||||
formatter.timeZone = TimeZone(secondsFromGMT: 0)
|
||||
return formatter.string(from: date)
|
||||
}
|
||||
|
||||
private static func nonEmpty(_ value: String?) -> String? {
|
||||
guard let value else {
|
||||
return nil
|
||||
}
|
||||
|
||||
let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return trimmed.isEmpty ? nil : trimmed
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import Foundation
|
||||
|
||||
struct PPGThermalMonitor {
|
||||
func currentState() -> ThermalState {
|
||||
switch ProcessInfo.processInfo.thermalState {
|
||||
case .nominal:
|
||||
return .nominal
|
||||
case .fair:
|
||||
return .fair
|
||||
case .serious:
|
||||
return .serious
|
||||
case .critical:
|
||||
return .critical
|
||||
@unknown default:
|
||||
return .unknown
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import Foundation
|
||||
|
||||
/// Pure, testable safety gate used before and during camera acquisition.
|
||||
/// Serious and critical states stop the measurement; unknown is conservative
|
||||
/// only in the sense that no torch escalation is attempted elsewhere.
|
||||
public enum PPGThermalSafetyPolicy {
|
||||
public static func shouldStopMeasurement(forStateName stateName: String) -> Bool {
|
||||
switch stateName.lowercased() {
|
||||
case "serious", "critical":
|
||||
return true
|
||||
case "nominal", "fair", "unknown":
|
||||
return false
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import Foundation
|
||||
import React
|
||||
|
||||
/// Thin React Native facade over the central WCSession owner. The PPG and
|
||||
/// Core Haptics objects remain Nitro modules; connectivity is intentionally a
|
||||
/// process-wide service and therefore must not be instantiated per HybridObject.
|
||||
@objc(HaptikaWatchConnectivity)
|
||||
final class HaptikaWatchConnectivityModule: NSObject {
|
||||
@objc static func requiresMainQueueSetup() -> Bool { false }
|
||||
|
||||
@objc func start() {
|
||||
HaptikaWatchConnectivityService.shared.activate()
|
||||
}
|
||||
|
||||
@objc func getConnectionState(
|
||||
_ resolve: RCTPromiseResolveBlock,
|
||||
rejecter reject: RCTPromiseRejectBlock
|
||||
) {
|
||||
let state = HaptikaWatchConnectivityService.shared.state()
|
||||
resolve([
|
||||
"supported": state.supported,
|
||||
"paired": state.paired,
|
||||
"watchAppInstalled": state.installed,
|
||||
"reachable": state.reachable,
|
||||
"activationState": state.activationState,
|
||||
"latestEventId": state.latestEventId ?? NSNull(),
|
||||
"latestEventAt": state.latestEventAt ?? NSNull()
|
||||
])
|
||||
}
|
||||
|
||||
@objc func drainEvents(
|
||||
_ resolve: RCTPromiseResolveBlock,
|
||||
rejecter reject: RCTPromiseRejectBlock
|
||||
) {
|
||||
resolve(HaptikaWatchConnectivityService.shared.drainEvents())
|
||||
}
|
||||
|
||||
@objc func acknowledgeEvents(_ eventIds: [String]) {
|
||||
HaptikaWatchConnectivityService.shared.acknowledge(eventIds: eventIds)
|
||||
}
|
||||
|
||||
@objc func sendPreferences(
|
||||
_ json: String,
|
||||
resolver resolve: RCTPromiseResolveBlock,
|
||||
rejecter reject: RCTPromiseRejectBlock
|
||||
) {
|
||||
resolve(HaptikaWatchConnectivityService.shared.sendPreferences(json: json))
|
||||
}
|
||||
|
||||
@objc func sendCommand(
|
||||
_ json: String,
|
||||
resolver resolve: RCTPromiseResolveBlock,
|
||||
rejecter reject: RCTPromiseRejectBlock
|
||||
) {
|
||||
resolve(HaptikaWatchConnectivityService.shared.sendCommand(json: json))
|
||||
}
|
||||
|
||||
@objc func transferEvent(
|
||||
_ json: String,
|
||||
resolver resolve: RCTPromiseResolveBlock,
|
||||
rejecter reject: RCTPromiseRejectBlock
|
||||
) {
|
||||
resolve(HaptikaWatchConnectivityService.shared.transferEvent(json: json))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
#import <React/RCTBridgeModule.h>
|
||||
|
||||
@interface RCT_EXTERN_MODULE(HaptikaWatchConnectivity, NSObject)
|
||||
|
||||
RCT_EXTERN_METHOD(start)
|
||||
RCT_EXTERN_METHOD(getConnectionState:(RCTPromiseResolveBlock)resolve
|
||||
rejecter:(RCTPromiseRejectBlock)reject)
|
||||
RCT_EXTERN_METHOD(drainEvents:(RCTPromiseResolveBlock)resolve
|
||||
rejecter:(RCTPromiseRejectBlock)reject)
|
||||
RCT_EXTERN_METHOD(acknowledgeEvents:(NSArray<NSString *> *)eventIds)
|
||||
RCT_EXTERN_METHOD(sendPreferences:(NSString *)json
|
||||
resolver:(RCTPromiseResolveBlock)resolve
|
||||
rejecter:(RCTPromiseRejectBlock)reject)
|
||||
RCT_EXTERN_METHOD(sendCommand:(NSString *)json
|
||||
resolver:(RCTPromiseResolveBlock)resolve
|
||||
rejecter:(RCTPromiseRejectBlock)reject)
|
||||
RCT_EXTERN_METHOD(transferEvent:(NSString *)json
|
||||
resolver:(RCTPromiseResolveBlock)resolve
|
||||
rejecter:(RCTPromiseRejectBlock)reject)
|
||||
|
||||
@end
|
||||
@@ -0,0 +1,490 @@
|
||||
import Foundation
|
||||
import WatchConnectivity
|
||||
|
||||
/// The only owner of `WCSession` in the iPhone process.
|
||||
///
|
||||
/// Durable Watch envelopes are persisted before being exposed to JavaScript.
|
||||
/// JavaScript explicitly acknowledges imported event IDs, after which the
|
||||
/// service removes them and sends an acknowledgement to the paired Watch.
|
||||
final class HaptikaWatchConnectivityService: NSObject, WCSessionDelegate {
|
||||
static let shared = HaptikaWatchConnectivityService()
|
||||
|
||||
struct ConnectionState {
|
||||
let supported: Bool
|
||||
let paired: Bool
|
||||
let installed: Bool
|
||||
let reachable: Bool
|
||||
let activationState: String
|
||||
let latestEventId: String?
|
||||
let latestEventAt: String?
|
||||
}
|
||||
|
||||
struct LiveHeartRate {
|
||||
let bpm: Double
|
||||
let timestampSeconds: Double
|
||||
}
|
||||
|
||||
private static let inboxKey = "HaptikaWatchConnectivity.inbox.v1"
|
||||
private let lock = NSLock()
|
||||
private let defaults = UserDefaults.standard
|
||||
private var memoryLiveEvents: [String] = []
|
||||
private var latestEventIdValue: String?
|
||||
private var latestEventAtValue: String?
|
||||
private var latestHeartRateValue: LiveHeartRate?
|
||||
|
||||
private override init() {
|
||||
super.init()
|
||||
}
|
||||
|
||||
func activate() {
|
||||
guard WCSession.isSupported() else { return }
|
||||
let session = WCSession.default
|
||||
if session.delegate !== self {
|
||||
session.delegate = self
|
||||
}
|
||||
if session.activationState != .activated {
|
||||
session.activate()
|
||||
}
|
||||
}
|
||||
|
||||
func state() -> ConnectionState {
|
||||
guard WCSession.isSupported() else {
|
||||
return ConnectionState(supported: false, paired: false, installed: false, reachable: false, activationState: "notActivated", latestEventId: nil, latestEventAt: nil)
|
||||
}
|
||||
let session = WCSession.default
|
||||
lock.lock()
|
||||
let eventId = latestEventIdValue
|
||||
let eventAt = latestEventAtValue
|
||||
lock.unlock()
|
||||
let activation: String
|
||||
switch session.activationState {
|
||||
case .activated: activation = "activated"
|
||||
case .inactive: activation = "inactive"
|
||||
case .notActivated: activation = "notActivated"
|
||||
@unknown default: activation = "notActivated"
|
||||
}
|
||||
return ConnectionState(
|
||||
supported: true,
|
||||
paired: session.isPaired,
|
||||
installed: session.isWatchAppInstalled,
|
||||
reachable: session.isReachable,
|
||||
activationState: activation,
|
||||
latestEventId: eventId,
|
||||
latestEventAt: eventAt
|
||||
)
|
||||
}
|
||||
|
||||
func latestHeartRate() -> LiveHeartRate? {
|
||||
lock.lock()
|
||||
defer { lock.unlock() }
|
||||
return latestHeartRateValue
|
||||
}
|
||||
|
||||
func drainEvents() -> [String] {
|
||||
lock.lock()
|
||||
defer { lock.unlock() }
|
||||
let durable = defaults.stringArray(forKey: Self.inboxKey) ?? []
|
||||
return durable + memoryLiveEvents
|
||||
}
|
||||
|
||||
func acknowledge(eventIds: [String]) {
|
||||
guard !eventIds.isEmpty else { return }
|
||||
let identifiers = Set(eventIds)
|
||||
lock.lock()
|
||||
let durable = defaults.stringArray(forKey: Self.inboxKey) ?? []
|
||||
defaults.set(durable.filter { json in
|
||||
guard let eventId = Self.eventId(in: json) else { return false }
|
||||
return !identifiers.contains(eventId)
|
||||
}, forKey: Self.inboxKey)
|
||||
memoryLiveEvents.removeAll { json in
|
||||
guard let eventId = Self.eventId(in: json) else { return true }
|
||||
return identifiers.contains(eventId)
|
||||
}
|
||||
lock.unlock()
|
||||
|
||||
sendAcknowledgement(eventIds: eventIds)
|
||||
}
|
||||
|
||||
func sendPreferences(json: String) -> Bool {
|
||||
guard let dictionary = Self.dictionary(from: json), WCSession.isSupported() else { return false }
|
||||
let rawPayload = (dictionary["payload"] as? [String: Any]) ?? dictionary
|
||||
let save = (rawPayload["saveMindAndBodyWorkouts"] as? Bool) ?? true
|
||||
let catalogVersion = (rawPayload["protocolCatalogVersion"] as? String) ?? "1.0.0"
|
||||
let preferences: [String: Any] = [
|
||||
"schemaVersion": 1,
|
||||
"saveMindAndBodyWorkouts": save,
|
||||
"protocolCatalogVersion": catalogVersion,
|
||||
"updatedAt": (rawPayload["updatedAt"] as? String) ?? Self.iso8601.string(from: Date())
|
||||
]
|
||||
guard let data = try? JSONSerialization.data(withJSONObject: preferences, options: [.sortedKeys]) else { return false }
|
||||
activate()
|
||||
do {
|
||||
try WCSession.default.updateApplicationContext(["preferences": data])
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func sendCommand(json: String) -> Bool {
|
||||
guard let dictionary = Self.dictionary(from: json), WCSession.isSupported() else { return false }
|
||||
activate()
|
||||
guard WCSession.default.isReachable else { return false }
|
||||
let payload = (dictionary["payload"] as? [String: Any]) ?? dictionary
|
||||
guard let envelope = Self.watchWireEnvelope(kind: "mirrorCommand", payload: payload),
|
||||
let encoded = try? JSONSerialization.data(withJSONObject: envelope, options: [.sortedKeys]) else { return false }
|
||||
WCSession.default.sendMessageData(encoded, replyHandler: nil, errorHandler: nil)
|
||||
return true
|
||||
}
|
||||
|
||||
func transferEvent(json: String) -> Bool {
|
||||
guard let dictionary = Self.dictionary(from: json), WCSession.isSupported(),
|
||||
let kind = dictionary["kind"] as? String,
|
||||
let payloadObject = dictionary["payload"],
|
||||
JSONSerialization.isValidJSONObject(payloadObject),
|
||||
let envelope = Self.watchWireEnvelope(kind: kind, payload: payloadObject),
|
||||
let encoded = try? JSONSerialization.data(withJSONObject: envelope, options: [.sortedKeys]) else { return false }
|
||||
activate()
|
||||
WCSession.default.transferUserInfo(["envelope": encoded])
|
||||
return true
|
||||
}
|
||||
|
||||
private func receive(_ dictionary: [String: Any], durable: Bool) {
|
||||
guard let normalized = normalize(dictionary),
|
||||
let data = try? JSONSerialization.data(withJSONObject: normalized, options: [.sortedKeys]),
|
||||
let json = String(data: data, encoding: .utf8) else { return }
|
||||
let kind = normalized["kind"] as? String
|
||||
let eventId = normalized["eventId"] as? String
|
||||
let sentAt = normalized["sentAt"] as? String
|
||||
|
||||
lock.lock()
|
||||
latestEventIdValue = eventId
|
||||
latestEventAtValue = sentAt
|
||||
if kind == "liveHeartRate", let payload = normalized["payload"] as? [String: Any],
|
||||
let bpm = Self.double(payload["bpm"]), bpm >= 35, bpm <= 220,
|
||||
let timestamp = Self.double(payload["timestampSeconds"]), timestamp.isFinite {
|
||||
latestHeartRateValue = LiveHeartRate(bpm: bpm, timestampSeconds: timestamp)
|
||||
}
|
||||
if durable {
|
||||
var inbox = defaults.stringArray(forKey: Self.inboxKey) ?? []
|
||||
if eventId == nil || !inbox.contains(where: { Self.eventId(in: $0) == eventId }) {
|
||||
inbox.append(json)
|
||||
defaults.set(Array(inbox.suffix(500)), forKey: Self.inboxKey)
|
||||
}
|
||||
} else {
|
||||
memoryLiveEvents.append(json)
|
||||
if memoryLiveEvents.count > 60 { memoryLiveEvents.removeFirst(memoryLiveEvents.count - 60) }
|
||||
}
|
||||
lock.unlock()
|
||||
}
|
||||
|
||||
private func normalize(_ dictionary: [String: Any]) -> [String: Any]? {
|
||||
if dictionary["kind"] != nil,
|
||||
dictionary["eventId"] != nil || dictionary["eventID"] != nil {
|
||||
return normalizeEnvelopeDictionary(dictionary)
|
||||
}
|
||||
// Compatibility with the early prototype. It remains a live HR sample and
|
||||
// is never treated as a biological heartbeat.
|
||||
guard let bpm = Self.double(dictionary["bpm"]), bpm >= 35, bpm <= 220,
|
||||
let timestamp = Self.double(dictionary["timestampSeconds"]) else { return nil }
|
||||
return [
|
||||
"schemaVersion": 1,
|
||||
"eventId": "legacy-live-\(UUID().uuidString)",
|
||||
"deviceId": "legacy-watch",
|
||||
"sequence": 0,
|
||||
"sentAt": Self.iso8601.string(from: Date()),
|
||||
"kind": "liveHeartRate",
|
||||
"payload": ["bpm": bpm, "timestampSeconds": timestamp]
|
||||
]
|
||||
}
|
||||
|
||||
private func normalizeEnvelopeDictionary(_ dictionary: [String: Any]) -> [String: Any]? {
|
||||
let eventId = (dictionary["eventId"] ?? dictionary["eventID"]) as? String
|
||||
let deviceId = (dictionary["deviceId"] ?? dictionary["deviceID"]) as? String
|
||||
guard let eventId, let deviceId, let kindValue = dictionary["kind"] as? String else { return nil }
|
||||
let kind = kindValue == "acknowledgement" ? "ack" : kindValue
|
||||
let sentAt = (dictionary["sentAt"] as? String) ?? Self.iso8601.string(from: Date())
|
||||
let sequence = Self.double(dictionary["sequence"]) ?? 0
|
||||
let payloadObject: Any
|
||||
if let payload = dictionary["payload"] as? [String: Any] {
|
||||
payloadObject = payload
|
||||
} else if let base64 = dictionary["payload"] as? String,
|
||||
let data = Data(base64Encoded: base64),
|
||||
let decoded = try? JSONSerialization.jsonObject(with: data) {
|
||||
payloadObject = decoded
|
||||
} else if let data = dictionary["payload"] as? Data,
|
||||
let decoded = try? JSONSerialization.jsonObject(with: data) {
|
||||
payloadObject = decoded
|
||||
} else {
|
||||
payloadObject = NSNull()
|
||||
}
|
||||
let normalizedPayload = normalizePayload(payloadObject, kind: kind, deviceId: deviceId, sentAt: sentAt)
|
||||
return [
|
||||
"schemaVersion": 1,
|
||||
"eventId": eventId,
|
||||
"deviceId": deviceId,
|
||||
"sequence": sequence,
|
||||
"sentAt": sentAt,
|
||||
"kind": kind,
|
||||
"payload": normalizedPayload
|
||||
]
|
||||
}
|
||||
|
||||
private func normalizePayload(_ payload: Any, kind: String, deviceId: String, sentAt: String) -> Any {
|
||||
guard let value = payload as? [String: Any] else { return payload }
|
||||
// Current watchOS builds encode the exact cross-platform V1 DTO. Do not
|
||||
// reinterpret canonical fields such as `coverage`, `currentBpm` or the
|
||||
// compact sample offsets.
|
||||
if Self.double(value["schemaVersion"]) == 1 {
|
||||
if kind == "measurementUpsert" {
|
||||
var canonical = value
|
||||
for key in ["currentBpm", "averageBpm", "minimumBpm", "maximumBpm", "invalidReason", "feeling"] where canonical[key] == nil {
|
||||
canonical[key] = NSNull()
|
||||
}
|
||||
if var device = canonical["device"] as? [String: Any] {
|
||||
if device["model"] == nil { device["model"] = NSNull() }
|
||||
if device["systemVersion"] == nil { device["systemVersion"] = NSNull() }
|
||||
canonical["device"] = device
|
||||
}
|
||||
if var context = canonical["context"] as? [String: Any] {
|
||||
if context["posture"] == nil { context["posture"] = NSNull() }
|
||||
for key in ["recentExercise", "recentCaffeine", "recentMeal"] where context[key] == nil {
|
||||
context[key] = NSNull()
|
||||
}
|
||||
canonical["context"] = context
|
||||
}
|
||||
return canonical
|
||||
}
|
||||
if kind == "regulationSessionUpsert" {
|
||||
var canonical = value
|
||||
for key in ["beforeMeasurementId", "afterMeasurementId", "endedAt", "workoutUUID", "feelingBefore", "feelingAfter"] where canonical[key] == nil {
|
||||
canonical[key] = NSNull()
|
||||
}
|
||||
return canonical
|
||||
}
|
||||
if ["tombstone", "preferences"].contains(kind) { return value }
|
||||
}
|
||||
switch kind {
|
||||
case "measurementUpsert":
|
||||
guard let id = value["id"] as? String,
|
||||
let startedAt = value["startedAt"] as? String,
|
||||
let endedAt = value["endedAt"] as? String else { return value }
|
||||
let updatedAt = (value["updatedAt"] as? String) ?? sentAt
|
||||
let startedSeconds = Self.timestamp(startedAt) ?? 0
|
||||
let duration = max(1, (Self.timestamp(endedAt) ?? startedSeconds + 60) - startedSeconds)
|
||||
let coverageSeconds = Self.double(value["coverageSeconds"]) ?? 0
|
||||
let rawSamples = value["samples"] as? [[String: Any]] ?? []
|
||||
let samples: [[String: Any]] = rawSamples.compactMap { sample in
|
||||
guard let bpm = Self.double(sample["bpm"]),
|
||||
let date = sample["date"] as? String,
|
||||
let sampleTimestamp = Self.timestamp(date) else { return nil }
|
||||
return ["offsetSeconds": max(0, sampleTimestamp - startedSeconds), "bpm": bpm]
|
||||
}
|
||||
let context = value["context"] as? [String: Any] ?? [:]
|
||||
let postureValue = context["posture"] as? String
|
||||
let posture: Any = ["seated", "lying", "standing"].contains(postureValue ?? "")
|
||||
? Self.jsonString(postureValue)
|
||||
: NSNull()
|
||||
let reasons = value["invalidReasons"] as? [String] ?? []
|
||||
let feeling: Any
|
||||
if let score = Self.double(value["feeling"]), (1 ... 5).contains(score) {
|
||||
feeling = ["score": Int(score), "recordedAt": updatedAt]
|
||||
} else {
|
||||
feeling = NSNull()
|
||||
}
|
||||
let devicePayload: [String: Any] = [
|
||||
"id": deviceId,
|
||||
"name": (value["deviceName"] as? String) ?? "Apple Watch",
|
||||
"model": Self.jsonString(value["deviceModel"]),
|
||||
"systemVersion": NSNull()
|
||||
]
|
||||
let contextPayload: [String: Any] = [
|
||||
"atRest": (context["isAtRest"] as? Bool) ?? true,
|
||||
"posture": posture,
|
||||
"recentExercise": (context["recentExercise"] as? Bool) ?? false,
|
||||
"recentCaffeine": (context["recentCaffeine"] as? Bool) ?? false,
|
||||
"recentMeal": (context["recentMeal"] as? Bool) ?? false
|
||||
]
|
||||
let normalizedCoverage = min(1.0, max(0.0, coverageSeconds / duration))
|
||||
let normalizedQuality = min(1.0, max(0.0, Self.double(value["quality"]) ?? 0.0))
|
||||
let invalidReason: Any = reasons.isEmpty ? NSNull() : reasons.joined(separator: ",")
|
||||
|
||||
var normalized: [String: Any] = [:]
|
||||
normalized["schemaVersion"] = 1
|
||||
normalized["id"] = id
|
||||
normalized["updatedAt"] = updatedAt
|
||||
normalized["startedAt"] = startedAt
|
||||
normalized["endedAt"] = endedAt
|
||||
normalized["source"] = "appleWatchOptical"
|
||||
normalized["device"] = devicePayload
|
||||
normalized["currentBpm"] = Self.jsonNumber(value["currentBPM"])
|
||||
normalized["averageBpm"] = Self.jsonNumber(value["averageBPM"])
|
||||
normalized["minimumBpm"] = Self.jsonNumber(value["minimumBPM"])
|
||||
normalized["maximumBpm"] = Self.jsonNumber(value["maximumBPM"])
|
||||
normalized["samples"] = samples
|
||||
normalized["coverage"] = normalizedCoverage
|
||||
normalized["quality"] = normalizedQuality
|
||||
normalized["valid"] = (value["isValid"] as? Bool) ?? false
|
||||
normalized["invalidReason"] = invalidReason
|
||||
normalized["context"] = contextPayload
|
||||
normalized["feeling"] = feeling
|
||||
return normalized
|
||||
case "regulationSessionUpsert":
|
||||
let sourceState = value["state"] as? String
|
||||
let normalizedState = sourceState == "interrupted" ? "cancelled" : (sourceState ?? "cancelled")
|
||||
var normalized: [String: Any] = [:]
|
||||
normalized["schemaVersion"] = 1
|
||||
normalized["id"] = value["id"] as? String ?? ""
|
||||
normalized["updatedAt"] = value["updatedAt"] as? String ?? sentAt
|
||||
normalized["protocolId"] = value["protocolID"] as? String ?? ""
|
||||
normalized["beforeMeasurementId"] = value["beforeMeasurementID"] ?? NSNull()
|
||||
normalized["afterMeasurementId"] = value["afterMeasurementID"] ?? NSNull()
|
||||
normalized["startedAt"] = value["startedAt"] as? String ?? sentAt
|
||||
normalized["endedAt"] = value["endedAt"] ?? NSNull()
|
||||
normalized["durationSeconds"] = Self.double(value["durationSeconds"]) ?? 0.0
|
||||
normalized["state"] = normalizedState
|
||||
normalized["workoutUUID"] = value["healthKitWorkoutID"] ?? NSNull()
|
||||
normalized["feelingBefore"] = value["feelingBefore"] ?? NSNull()
|
||||
normalized["feelingAfter"] = value["feelingAfter"] ?? NSNull()
|
||||
return normalized
|
||||
case "tombstone":
|
||||
let deletedAt = value["deletedAt"] as? String ?? sentAt
|
||||
return [
|
||||
"schemaVersion": 1,
|
||||
"entityId": (value["entityId"] ?? value["entityID"]) as? String ?? "",
|
||||
"entityKind": value["entityKind"] as? String ?? "measurement",
|
||||
"deletedAt": deletedAt,
|
||||
"updatedAt": deletedAt
|
||||
]
|
||||
case "liveHeartRate":
|
||||
let sampledAt = value["sampledAt"] as? String
|
||||
return [
|
||||
"bpm": Self.double(value["bpm"]) ?? 0,
|
||||
"timestampSeconds": sampledAt.flatMap(Self.timestamp) ?? Date().timeIntervalSince1970,
|
||||
"measurementId": value["measurementID"] ?? NSNull()
|
||||
]
|
||||
default:
|
||||
return value
|
||||
}
|
||||
}
|
||||
|
||||
private func sendAcknowledgement(eventIds: [String]) {
|
||||
guard WCSession.isSupported() else { return }
|
||||
activate()
|
||||
let validEventIds = eventIds.compactMap(UUID.init(uuidString:)).map(\.uuidString)
|
||||
guard !validEventIds.isEmpty else { return }
|
||||
let payload: [String: Any] = ["eventIDs": validEventIds, "receivedAt": Self.iso8601.string(from: Date())]
|
||||
guard let envelope = Self.watchWireEnvelope(kind: "ack", payload: payload),
|
||||
let envelopeData = try? JSONSerialization.data(withJSONObject: envelope, options: [.sortedKeys]) else { return }
|
||||
if WCSession.default.isReachable {
|
||||
WCSession.default.sendMessageData(envelopeData, replyHandler: nil) { _ in
|
||||
WCSession.default.transferUserInfo(["envelope": envelopeData])
|
||||
}
|
||||
} else {
|
||||
WCSession.default.transferUserInfo(["envelope": envelopeData])
|
||||
}
|
||||
}
|
||||
|
||||
func session(_ session: WCSession, activationDidCompleteWith activationState: WCSessionActivationState, error: Error?) {}
|
||||
func sessionDidBecomeInactive(_ session: WCSession) {}
|
||||
func sessionDidDeactivate(_ session: WCSession) { session.activate() }
|
||||
func sessionReachabilityDidChange(_ session: WCSession) {}
|
||||
func sessionWatchStateDidChange(_ session: WCSession) {}
|
||||
|
||||
func session(_ session: WCSession, didReceiveMessage message: [String: Any]) {
|
||||
receive(message, durable: (message["kind"] as? String) != "liveHeartRate")
|
||||
}
|
||||
|
||||
func session(_ session: WCSession, didReceiveMessageData messageData: Data) {
|
||||
receiveEnvelopeData(messageData, durable: false)
|
||||
}
|
||||
|
||||
func session(_ session: WCSession, didReceiveMessageData messageData: Data, replyHandler: @escaping (Data) -> Void) {
|
||||
receiveEnvelopeData(messageData, durable: false)
|
||||
replyHandler(Data())
|
||||
}
|
||||
|
||||
func session(_ session: WCSession, didReceiveMessage message: [String: Any], replyHandler: @escaping ([String: Any]) -> Void) {
|
||||
receive(message, durable: (message["kind"] as? String) != "liveHeartRate")
|
||||
replyHandler(["accepted": true])
|
||||
}
|
||||
|
||||
func session(_ session: WCSession, didReceiveUserInfo userInfo: [String: Any]) {
|
||||
if let envelope = userInfo["envelope"] as? Data {
|
||||
receiveEnvelopeData(envelope, durable: true)
|
||||
} else {
|
||||
receive(userInfo, durable: true)
|
||||
}
|
||||
}
|
||||
|
||||
func session(_ session: WCSession, didReceiveApplicationContext applicationContext: [String: Any]) {
|
||||
receive(applicationContext, durable: true)
|
||||
}
|
||||
|
||||
private static let iso8601: ISO8601DateFormatter = {
|
||||
let formatter = ISO8601DateFormatter()
|
||||
formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
|
||||
return formatter
|
||||
}()
|
||||
|
||||
private static func dictionary(from json: String) -> [String: Any]? {
|
||||
guard let data = json.data(using: .utf8),
|
||||
let object = try? JSONSerialization.jsonObject(with: data),
|
||||
let dictionary = object as? [String: Any],
|
||||
JSONSerialization.isValidJSONObject(dictionary) else { return nil }
|
||||
return dictionary
|
||||
}
|
||||
|
||||
private static func eventId(in json: String) -> String? {
|
||||
return dictionary(from: json)?["eventId"] as? String
|
||||
}
|
||||
|
||||
private static func double(_ value: Any?) -> Double? {
|
||||
if let number = value as? NSNumber { return number.doubleValue }
|
||||
return value as? Double
|
||||
}
|
||||
|
||||
private func receiveEnvelopeData(_ data: Data, durable: Bool) {
|
||||
guard let object = try? JSONSerialization.jsonObject(with: data),
|
||||
let dictionary = object as? [String: Any] else { return }
|
||||
receive(dictionary, durable: durable || (dictionary["kind"] as? String) != "liveHeartRate")
|
||||
}
|
||||
|
||||
private static func timestamp(_ value: String) -> TimeInterval? {
|
||||
if let date = iso8601.date(from: value) { return date.timeIntervalSince1970 }
|
||||
let fallback = ISO8601DateFormatter()
|
||||
fallback.formatOptions = [.withInternetDateTime]
|
||||
return fallback.date(from: value)?.timeIntervalSince1970
|
||||
}
|
||||
|
||||
private static func jsonNumber(_ value: Any?) -> Any {
|
||||
return double(value).map { NSNumber(value: $0) } ?? NSNull()
|
||||
}
|
||||
|
||||
private static func jsonString(_ value: Any?) -> Any {
|
||||
return (value as? String).map { $0 as Any } ?? NSNull()
|
||||
}
|
||||
|
||||
private static func watchWireEnvelope(kind: String, payload: Any) -> [String: Any]? {
|
||||
guard JSONSerialization.isValidJSONObject(payload) else { return nil }
|
||||
return [
|
||||
"schemaVersion": 1,
|
||||
"eventId": UUID().uuidString,
|
||||
"deviceId": UIDeviceIdentifier.current,
|
||||
"sequence": 0,
|
||||
"sentAt": iso8601.string(from: Date()),
|
||||
"kind": kind,
|
||||
"payload": payload
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
private enum UIDeviceIdentifier {
|
||||
static let current: String = {
|
||||
let key = "HaptikaWatchConnectivity.deviceId.v1"
|
||||
if let existing = UserDefaults.standard.string(forKey: key) { return existing }
|
||||
let generated = "iphone-\(UUID().uuidString)"
|
||||
UserDefaults.standard.set(generated, forKey: key)
|
||||
return generated
|
||||
}()
|
||||
}
|
||||
Reference in New Issue
Block a user