feat: implement Haptika PPG and Watch V1
This commit is contained in:
@@ -0,0 +1,242 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user