136 lines
5.3 KiB
Swift
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
|
|
)
|
|
}
|
|
}
|