feat: implement Haptika PPG and Watch V1
This commit is contained in:
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user