feat: implement Haptika PPG and Watch V1

This commit is contained in:
Sacha
2026-07-31 16:19:42 +02:00
parent 6b7d4e0728
commit b9930b7378
249 changed files with 46475 additions and 122 deletions
@@ -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)
}
}