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 /// 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 = [], 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.. 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) } }