Files
2026-07-31 16:20:10 +02:00

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