243 lines
9.4 KiB
Swift
243 lines
9.4 KiB
Swift
import Foundation
|
|
|
|
/// A deterministic artifact window used by ``PPGSyntheticSignalConfiguration``.
|
|
public struct PPGSyntheticArtifactWindow: Codable, Equatable, Sendable {
|
|
public var startSeconds: Double
|
|
public var endSeconds: Double
|
|
public var intensity: Double
|
|
|
|
public init(startSeconds: Double, endSeconds: Double, intensity: Double = 1) {
|
|
self.startSeconds = max(startSeconds.isFinite ? startSeconds : 0, 0)
|
|
self.endSeconds = max(endSeconds.isFinite ? endSeconds : 0, self.startSeconds)
|
|
self.intensity = min(max(intensity.isFinite ? intensity : 0, 0), 1)
|
|
}
|
|
|
|
func intensity(at time: Double) -> Double {
|
|
(startSeconds...endSeconds).contains(time) ? intensity : 0
|
|
}
|
|
}
|
|
|
|
/// Configuration for a numeric PPG camera fixture.
|
|
///
|
|
/// The generator creates the same compact RGB/luminance/time values consumed by
|
|
/// production signal processing. It never creates or retains images. All random
|
|
/// noise is seeded, so a failing test can be reproduced byte for byte.
|
|
public struct PPGSyntheticSignalConfiguration: Codable, Equatable, Sendable {
|
|
public var sampleRateHz: Double
|
|
public var durationSeconds: Double
|
|
public var startingBPM: Double
|
|
public var endingBPM: Double
|
|
/// Repeating beat-to-beat offsets, in milliseconds, added to the nominal IPI.
|
|
public var intervalVariationMilliseconds: [Double]
|
|
public var noiseAmplitude: Double
|
|
public var driftAmplitude: Double
|
|
public var driftFrequencyHz: Double
|
|
public var movementWindows: [PPGSyntheticArtifactWindow]
|
|
public var saturationWindows: [PPGSyntheticArtifactWindow]
|
|
/// Every nth nominal camera frame is omitted. Nil keeps every frame.
|
|
public var dropEveryNthFrame: Int?
|
|
/// Suppresses the pulse cycle beginning at each listed zero-based beat index.
|
|
public var missingBeatIndices: Set<Int>
|
|
/// Adds a narrow artificial pulse at each supplied timestamp.
|
|
public var falsePeakTimesSeconds: [Double]
|
|
public var seed: UInt64
|
|
|
|
public init(
|
|
sampleRateHz: Double = 30,
|
|
durationSeconds: Double = 60,
|
|
startingBPM: Double = 75,
|
|
endingBPM: Double? = nil,
|
|
intervalVariationMilliseconds: [Double] = [],
|
|
noiseAmplitude: Double = 0,
|
|
driftAmplitude: Double = 0,
|
|
driftFrequencyHz: Double = 0.05,
|
|
movementWindows: [PPGSyntheticArtifactWindow] = [],
|
|
saturationWindows: [PPGSyntheticArtifactWindow] = [],
|
|
dropEveryNthFrame: Int? = nil,
|
|
missingBeatIndices: Set<Int> = [],
|
|
falsePeakTimesSeconds: [Double] = [],
|
|
seed: UInt64 = 0x4841_5054_494B_4101
|
|
) {
|
|
self.sampleRateHz = sampleRateHz
|
|
self.durationSeconds = durationSeconds
|
|
self.startingBPM = startingBPM
|
|
self.endingBPM = endingBPM ?? startingBPM
|
|
self.intervalVariationMilliseconds = intervalVariationMilliseconds
|
|
self.noiseAmplitude = max(noiseAmplitude, 0)
|
|
self.driftAmplitude = max(driftAmplitude, 0)
|
|
self.driftFrequencyHz = max(driftFrequencyHz, 0)
|
|
self.movementWindows = movementWindows
|
|
self.saturationWindows = saturationWindows
|
|
self.dropEveryNthFrame = dropEveryNthFrame
|
|
self.missingBeatIndices = missingBeatIndices
|
|
self.falsePeakTimesSeconds = falsePeakTimesSeconds
|
|
self.seed = seed
|
|
}
|
|
|
|
public func validated() throws -> Self {
|
|
guard sampleRateHz.isFinite, sampleRateHz >= 10, sampleRateHz <= 240 else {
|
|
throw PPGSignalError.invalidConfiguration("synthetic sampleRateHz must be in 10...240")
|
|
}
|
|
guard durationSeconds.isFinite, durationSeconds > 0 else {
|
|
throw PPGSignalError.invalidConfiguration("synthetic durationSeconds must be positive")
|
|
}
|
|
guard startingBPM.isFinite, endingBPM.isFinite,
|
|
(30...240).contains(startingBPM), (30...240).contains(endingBPM)
|
|
else {
|
|
throw PPGSignalError.invalidConfiguration("synthetic BPM must be in 30...240")
|
|
}
|
|
guard intervalVariationMilliseconds.allSatisfy({ $0.isFinite && abs($0) <= 300 }) else {
|
|
throw PPGSignalError.invalidConfiguration("synthetic interval variation must be finite and <= 300 ms")
|
|
}
|
|
if let dropEveryNthFrame, dropEveryNthFrame < 2 {
|
|
throw PPGSignalError.invalidConfiguration("dropEveryNthFrame must be at least 2")
|
|
}
|
|
return self
|
|
}
|
|
}
|
|
|
|
public struct PPGSyntheticSignal: Equatable, Sendable {
|
|
public var samples: [PPGFrameSample]
|
|
public var beatTimesSeconds: [Double]
|
|
public var nominalIntervalsMilliseconds: [Double]
|
|
|
|
public init(
|
|
samples: [PPGFrameSample],
|
|
beatTimesSeconds: [Double],
|
|
nominalIntervalsMilliseconds: [Double]
|
|
) {
|
|
self.samples = samples
|
|
self.beatTimesSeconds = beatTimesSeconds
|
|
self.nominalIntervalsMilliseconds = nominalIntervalsMilliseconds
|
|
}
|
|
}
|
|
|
|
public enum PPGSyntheticSignalGenerator {
|
|
public static func generate(
|
|
configuration input: PPGSyntheticSignalConfiguration
|
|
) throws -> PPGSyntheticSignal {
|
|
let configuration = try input.validated()
|
|
let beatSchedule = makeBeatSchedule(configuration)
|
|
let frameCount = Int((configuration.durationSeconds * configuration.sampleRateHz).rounded(.down)) + 1
|
|
var random = SeededRandomNumberGenerator(seed: configuration.seed)
|
|
var samples: [PPGFrameSample] = []
|
|
samples.reserveCapacity(frameCount)
|
|
|
|
var beatIndex = 0
|
|
for frameIndex in 0..<frameCount {
|
|
if let nth = configuration.dropEveryNthFrame,
|
|
frameIndex > 0, frameIndex.isMultiple(of: nth) {
|
|
continue
|
|
}
|
|
let time = Double(frameIndex) / configuration.sampleRateHz
|
|
while beatIndex + 1 < beatSchedule.times.count,
|
|
beatSchedule.times[beatIndex + 1] <= time {
|
|
beatIndex += 1
|
|
}
|
|
let currentBeat = beatSchedule.times[min(beatIndex, beatSchedule.times.count - 1)]
|
|
let nextBeat = beatIndex + 1 < beatSchedule.times.count
|
|
? beatSchedule.times[beatIndex + 1]
|
|
: currentBeat + 60 / configuration.endingBPM
|
|
let phase = min(max((time - currentBeat) / max(nextBeat - currentBeat, 1e-6), 0), 1)
|
|
var pulse = cos(2 * Double.pi * phase) + 0.18 * cos(4 * Double.pi * phase)
|
|
if configuration.missingBeatIndices.contains(beatIndex) { pulse = 0 }
|
|
for falsePeakTime in configuration.falsePeakTimesSeconds where falsePeakTime.isFinite {
|
|
let distance = (time - falsePeakTime) / 0.055
|
|
pulse += 1.25 * exp(-0.5 * distance * distance)
|
|
}
|
|
|
|
let drift = configuration.driftAmplitude
|
|
* sin(2 * Double.pi * configuration.driftFrequencyHz * time)
|
|
let movement = configuration.movementWindows.map { $0.intensity(at: time) }.max() ?? 0
|
|
let saturation = configuration.saturationWindows.map { $0.intensity(at: time) }.max() ?? 0
|
|
let baseNoise = random.nextGaussian() * configuration.noiseAmplitude
|
|
let movementNoise = random.nextGaussian() * movement * 16
|
|
let commonLight = 1 + drift + movementNoise * 0.006
|
|
|
|
var red = 176 * commonLight + 2.8 * pulse + 0.55 * baseNoise + movementNoise
|
|
var green = 92 * commonLight + 6.2 * pulse + baseNoise - 0.35 * movementNoise
|
|
var blue = 68 * commonLight + 1.8 * pulse + 0.45 * baseNoise + 0.20 * movementNoise
|
|
if saturation > 0 {
|
|
red = red * (1 - saturation) + 255 * saturation
|
|
green = green * (1 - saturation) + 255 * saturation
|
|
blue = blue * (1 - saturation) + 230 * saturation
|
|
}
|
|
red = min(max(red, 0), 255)
|
|
green = min(max(green, 0), 255)
|
|
blue = min(max(blue, 0), 255)
|
|
let luminance = 0.2126 * red + 0.7152 * green + 0.0722 * blue
|
|
samples.append(
|
|
PPGFrameSample(
|
|
timestampSeconds: time,
|
|
red: red,
|
|
green: green,
|
|
blue: blue,
|
|
luminance: luminance,
|
|
contactConfidence: max(0.95 - 0.25 * movement, 0),
|
|
saturationRatio: saturation,
|
|
motionArtifactScore: movement
|
|
)
|
|
)
|
|
}
|
|
|
|
return PPGSyntheticSignal(
|
|
samples: samples,
|
|
beatTimesSeconds: beatSchedule.times,
|
|
nominalIntervalsMilliseconds: beatSchedule.intervals
|
|
)
|
|
}
|
|
|
|
private static func makeBeatSchedule(
|
|
_ configuration: PPGSyntheticSignalConfiguration
|
|
) -> (times: [Double], intervals: [Double]) {
|
|
var times = [0.0]
|
|
var intervals: [Double] = []
|
|
var beatIndex = 0
|
|
while let last = times.last, last < configuration.durationSeconds {
|
|
let progress = min(max(last / configuration.durationSeconds, 0), 1)
|
|
let bpm = configuration.startingBPM
|
|
+ (configuration.endingBPM - configuration.startingBPM) * progress
|
|
let variation = configuration.intervalVariationMilliseconds.isEmpty
|
|
? 0
|
|
: configuration.intervalVariationMilliseconds[
|
|
beatIndex % configuration.intervalVariationMilliseconds.count
|
|
]
|
|
let intervalMs = max(60_000 / bpm + variation, 250)
|
|
intervals.append(intervalMs)
|
|
times.append(last + intervalMs / 1_000)
|
|
beatIndex += 1
|
|
}
|
|
return (times, intervals)
|
|
}
|
|
}
|
|
|
|
private struct SeededRandomNumberGenerator: RandomNumberGenerator {
|
|
private var state: UInt64
|
|
private var spareGaussian: Double?
|
|
|
|
init(seed: UInt64) {
|
|
state = seed == 0 ? 0x9E37_79B9_7F4A_7C15 : seed
|
|
}
|
|
|
|
mutating func next() -> UInt64 {
|
|
state &+= 0x9E37_79B9_7F4A_7C15
|
|
var value = state
|
|
value = (value ^ (value >> 30)) &* 0xBF58_476D_1CE4_E5B9
|
|
value = (value ^ (value >> 27)) &* 0x94D0_49BB_1331_11EB
|
|
return value ^ (value >> 31)
|
|
}
|
|
|
|
mutating func nextGaussian() -> Double {
|
|
if let spareGaussian {
|
|
self.spareGaussian = nil
|
|
return spareGaussian
|
|
}
|
|
let u1 = max(Double(next()) / Double(UInt64.max), 1e-12)
|
|
let u2 = Double(next()) / Double(UInt64.max)
|
|
let radius = sqrt(-2 * log(u1))
|
|
let angle = 2 * Double.pi * u2
|
|
spareGaussian = radius * sin(angle)
|
|
return radius * cos(angle)
|
|
}
|
|
}
|