197 lines
7.0 KiB
Swift
197 lines
7.0 KiB
Swift
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))
|
|
}
|
|
}
|