Files
haptika/packages/react-native-haptika-ppg/ios/Signal/Resampler.swift
T
2026-07-31 16:20:10 +02:00

136 lines
5.3 KiB
Swift

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