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