feat: implement Haptika PPG and Watch V1

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