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,341 @@
import CoreMedia
import CoreVideo
import XCTest
@testable import HaptikaFrameMetrics
final class PPGFrameMetricsExtractorTests: XCTestCase {
func testFullRangeYUVExtractionUsesCentralROIAndUnlocksBuffer() throws {
let pixelBuffer = try makePixelBuffer(
width: 64,
height: 64,
pixelFormat: kCVPixelFormatType_420YpCbCr8BiPlanarFullRange
)
try fillBiPlanarYUV(pixelBuffer, y: 100, cb: 128, cr: 128)
let metrics = try PPGFrameMetricsExtractor(gridSize: 16, roiFraction: 0.5)
.extract(from: makeSampleBuffer(pixelBuffer: pixelBuffer, timestampSeconds: 4.25))
XCTAssertEqual(metrics.timestampSeconds, 4.25, accuracy: 0.000_001)
XCTAssertEqual(metrics.redMean, 100, accuracy: 0.000_001)
XCTAssertEqual(metrics.greenMean, 100, accuracy: 0.000_001)
XCTAssertEqual(metrics.blueMean, 100, accuracy: 0.000_001)
XCTAssertEqual(metrics.luminanceMean, 100, accuracy: 0.000_001)
try assertCanRelock(pixelBuffer)
}
func testVideoRangeYUVExtractionAppliesVideoRangeConversion() throws {
let pixelBuffer = try makePixelBuffer(
width: 48,
height: 48,
pixelFormat: kCVPixelFormatType_420YpCbCr8BiPlanarVideoRange
)
try fillBiPlanarYUV(pixelBuffer, y: 126, cb: 128, cr: 128)
let metrics = try PPGFrameMetricsExtractor(gridSize: 12, roiFraction: 0.5)
.extract(from: makeSampleBuffer(pixelBuffer: pixelBuffer, timestampSeconds: 2))
let expected = 1.164_383 * 110
XCTAssertEqual(metrics.redMean, expected, accuracy: 0.001)
XCTAssertEqual(metrics.greenMean, expected, accuracy: 0.001)
XCTAssertEqual(metrics.blueMean, expected, accuracy: 0.001)
try assertCanRelock(pixelBuffer)
}
func testBGRAExtractionSamplesOnlyCentralROI() throws {
let pixelBuffer = try makePixelBuffer(
width: 64,
height: 64,
pixelFormat: kCVPixelFormatType_32BGRA
)
try fillBGRA(pixelBuffer) { x, y in
if (16..<48).contains(x), (16..<48).contains(y) {
return (blue: 20, green: 80, red: 200, alpha: 255)
}
return (blue: 240, green: 10, red: 5, alpha: 255)
}
let sampleBuffer = try makeSampleBuffer(pixelBuffer: pixelBuffer, timestampSeconds: 12.5)
let metrics = try PPGFrameMetricsExtractor(gridSize: 16, roiFraction: 0.5)
.extract(from: sampleBuffer)
XCTAssertEqual(metrics.timestampSeconds, 12.5, accuracy: 0.000_001)
XCTAssertEqual(metrics.redMean, 200, accuracy: 0.000_001)
XCTAssertEqual(metrics.greenMean, 80, accuracy: 0.000_001)
XCTAssertEqual(metrics.blueMean, 20, accuracy: 0.000_001)
XCTAssertEqual(metrics.redVariance, 0, accuracy: 0.000_001)
XCTAssertEqual(metrics.greenVariance, 0, accuracy: 0.000_001)
XCTAssertEqual(
metrics.luminanceMean,
0.2126 * 200 + 0.7152 * 80 + 0.0722 * 20,
accuracy: 0.000_001
)
XCTAssertEqual(metrics.saturationRatio, 0, accuracy: 0.000_001)
try assertCanRelock(pixelBuffer)
}
func testBGRARedOnlyClippingKeepsUsableGreenChannel() throws {
let pixelBuffer = try makePixelBuffer(
width: 48,
height: 48,
pixelFormat: kCVPixelFormatType_32BGRA
)
try fillBGRA(pixelBuffer) { x, y in
let isCentral = (12..<36).contains(x) && (12..<36).contains(y)
return isCentral
? (blue: 40, green: 100, red: 255, alpha: 255)
: (blue: 0, green: 0, red: 0, alpha: 255)
}
let sampleBuffer = try makeSampleBuffer(pixelBuffer: pixelBuffer, timestampSeconds: 1)
let metrics = try PPGFrameMetricsExtractor(gridSize: 12, roiFraction: 0.5)
.extract(from: sampleBuffer)
XCTAssertEqual(metrics.redMean, 255, accuracy: 0.000_001)
XCTAssertEqual(metrics.saturationRatio, 0, accuracy: 0.000_001)
try assertCanRelock(pixelBuffer)
}
func testBGRASaturationRequiresBothUsefulChannelsToClip() throws {
let pixelBuffer = try makePixelBuffer(
width: 48,
height: 48,
pixelFormat: kCVPixelFormatType_32BGRA
)
try fillBGRA(pixelBuffer) { _, _ in
(blue: 80, green: 255, red: 255, alpha: 255)
}
let sampleBuffer = try makeSampleBuffer(pixelBuffer: pixelBuffer, timestampSeconds: 1)
let metrics = try PPGFrameMetricsExtractor(gridSize: 12, roiFraction: 0.5)
.extract(from: sampleBuffer)
XCTAssertEqual(metrics.saturationRatio, 1, accuracy: 0.000_001)
try assertCanRelock(pixelBuffer)
}
func testUnsupportedPixelFormatUnlocksBufferOnThrow() throws {
let pixelBuffer = try makePixelBuffer(
width: 32,
height: 32,
pixelFormat: kCVPixelFormatType_OneComponent8
)
let sampleBuffer = try makeSampleBuffer(pixelBuffer: pixelBuffer, timestampSeconds: 2)
XCTAssertThrowsError(try PPGFrameMetricsExtractor().extract(from: sampleBuffer)) { error in
guard case PPGFrameMetricsExtractorError.unsupportedPixelFormat(let format) = error else {
return XCTFail("Expected unsupportedPixelFormat, got \(error)")
}
XCTAssertEqual(format, kCVPixelFormatType_OneComponent8)
}
try assertCanRelock(pixelBuffer)
}
func testRepeatedExtractionDoesNotLeavePixelBuffersLocked() throws {
let extractor = PPGFrameMetricsExtractor(gridSize: 8, roiFraction: 0.5)
for index in 0..<100 {
autoreleasepool {
do {
let pixelBuffer = try makePixelBuffer(
width: 32,
height: 32,
pixelFormat: kCVPixelFormatType_32BGRA
)
try fillBGRA(pixelBuffer) { _, _ in (20, 90, 180, 255) }
_ = try extractor.extract(
from: makeSampleBuffer(
pixelBuffer: pixelBuffer,
timestampSeconds: Double(index) / 30
)
)
try assertCanRelock(pixelBuffer)
} catch {
XCTFail("Repeated extraction failed at \(index): \(error)")
}
}
}
}
private func makePixelBuffer(
width: Int,
height: Int,
pixelFormat: OSType
) throws -> CVPixelBuffer {
if pixelFormat == kCVPixelFormatType_420YpCbCr8BiPlanarFullRange
|| pixelFormat == kCVPixelFormatType_420YpCbCr8BiPlanarVideoRange {
return try makeBiPlanarPixelBuffer(width: width, height: height, pixelFormat: pixelFormat)
}
var pixelBuffer: CVPixelBuffer?
let status = CVPixelBufferCreate(
kCFAllocatorDefault,
width,
height,
pixelFormat,
nil,
&pixelBuffer
)
guard status == kCVReturnSuccess, let pixelBuffer else {
throw TestBufferError.pixelBufferCreation(status)
}
return pixelBuffer
}
private func makeBiPlanarPixelBuffer(
width: Int,
height: Int,
pixelFormat: OSType
) throws -> CVPixelBuffer {
let yBytesPerRow = width
let uvBytesPerRow = width
let ySize = yBytesPerRow * height
let uvHeight = height / 2
let totalSize = ySize + uvBytesPerRow * uvHeight
guard let storage = malloc(totalSize) else {
throw TestBufferError.missingBaseAddress
}
var planeAddresses: [UnsafeMutableRawPointer?] = [
storage,
storage.advanced(by: ySize),
]
var planeWidths = [width, width / 2]
var planeHeights = [height, uvHeight]
var planeBytesPerRow = [yBytesPerRow, uvBytesPerRow]
var pixelBuffer: CVPixelBuffer?
let status = CVPixelBufferCreateWithPlanarBytes(
kCFAllocatorDefault,
width,
height,
pixelFormat,
storage,
totalSize,
2,
&planeAddresses,
&planeWidths,
&planeHeights,
&planeBytesPerRow,
{ _, dataPointer, _, _, _ in
free(UnsafeMutableRawPointer(mutating: dataPointer))
},
nil,
nil,
&pixelBuffer
)
guard status == kCVReturnSuccess, let pixelBuffer else {
free(storage)
throw TestBufferError.pixelBufferCreation(status)
}
return pixelBuffer
}
private func fillBiPlanarYUV(
_ pixelBuffer: CVPixelBuffer,
y: UInt8,
cb: UInt8,
cr: UInt8
) throws {
let status = CVPixelBufferLockBaseAddress(pixelBuffer, [])
guard status == kCVReturnSuccess else {
throw TestBufferError.pixelBufferLock(status)
}
defer { CVPixelBufferUnlockBaseAddress(pixelBuffer, []) }
guard CVPixelBufferGetPlaneCount(pixelBuffer) >= 2,
let yBase = CVPixelBufferGetBaseAddressOfPlane(pixelBuffer, 0),
let uvBase = CVPixelBufferGetBaseAddressOfPlane(pixelBuffer, 1)
else {
throw TestBufferError.missingBaseAddress
}
let yHeight = CVPixelBufferGetHeightOfPlane(pixelBuffer, 0)
let yStride = CVPixelBufferGetBytesPerRowOfPlane(pixelBuffer, 0)
let yBytes = yBase.assumingMemoryBound(to: UInt8.self)
for row in 0..<yHeight {
yBytes.advanced(by: row * yStride).initialize(repeating: y, count: yStride)
}
let uvWidth = CVPixelBufferGetWidthOfPlane(pixelBuffer, 1)
let uvHeight = CVPixelBufferGetHeightOfPlane(pixelBuffer, 1)
let uvStride = CVPixelBufferGetBytesPerRowOfPlane(pixelBuffer, 1)
let uvBytes = uvBase.assumingMemoryBound(to: UInt8.self)
for row in 0..<uvHeight {
for column in 0..<uvWidth {
let offset = row * uvStride + column * 2
uvBytes[offset] = cb
uvBytes[offset + 1] = cr
}
}
}
private func fillBGRA(
_ pixelBuffer: CVPixelBuffer,
pixel: (_ x: Int, _ y: Int) -> (blue: UInt8, green: UInt8, red: UInt8, alpha: UInt8)
) throws {
let lockStatus = CVPixelBufferLockBaseAddress(pixelBuffer, [])
guard lockStatus == kCVReturnSuccess else {
throw TestBufferError.pixelBufferLock(lockStatus)
}
defer { CVPixelBufferUnlockBaseAddress(pixelBuffer, []) }
guard let baseAddress = CVPixelBufferGetBaseAddress(pixelBuffer) else {
throw TestBufferError.missingBaseAddress
}
let width = CVPixelBufferGetWidth(pixelBuffer)
let height = CVPixelBufferGetHeight(pixelBuffer)
let bytesPerRow = CVPixelBufferGetBytesPerRow(pixelBuffer)
let bytes = baseAddress.assumingMemoryBound(to: UInt8.self)
for y in 0..<height {
for x in 0..<width {
let color = pixel(x, y)
let offset = y * bytesPerRow + x * 4
bytes[offset] = color.blue
bytes[offset + 1] = color.green
bytes[offset + 2] = color.red
bytes[offset + 3] = color.alpha
}
}
}
private func makeSampleBuffer(
pixelBuffer: CVPixelBuffer,
timestampSeconds: Double
) throws -> CMSampleBuffer {
var formatDescription: CMVideoFormatDescription?
let formatStatus = CMVideoFormatDescriptionCreateForImageBuffer(
allocator: kCFAllocatorDefault,
imageBuffer: pixelBuffer,
formatDescriptionOut: &formatDescription
)
guard formatStatus == noErr, let formatDescription else {
throw TestBufferError.formatDescriptionCreation(formatStatus)
}
var timing = CMSampleTimingInfo(
duration: .invalid,
presentationTimeStamp: CMTime(seconds: timestampSeconds, preferredTimescale: 60_000),
decodeTimeStamp: .invalid
)
var sampleBuffer: CMSampleBuffer?
let sampleStatus = CMSampleBufferCreateReadyWithImageBuffer(
allocator: kCFAllocatorDefault,
imageBuffer: pixelBuffer,
formatDescription: formatDescription,
sampleTiming: &timing,
sampleBufferOut: &sampleBuffer
)
guard sampleStatus == noErr, let sampleBuffer else {
throw TestBufferError.sampleBufferCreation(sampleStatus)
}
return sampleBuffer
}
private func assertCanRelock(_ pixelBuffer: CVPixelBuffer) throws {
let status = CVPixelBufferLockBaseAddress(pixelBuffer, .readOnly)
guard status == kCVReturnSuccess else {
throw TestBufferError.pixelBufferLock(status)
}
CVPixelBufferUnlockBaseAddress(pixelBuffer, .readOnly)
}
}
private enum TestBufferError: Error {
case pixelBufferCreation(CVReturn)
case pixelBufferLock(CVReturn)
case missingBaseAddress
case formatDescriptionCreation(OSStatus)
case sampleBufferCreation(OSStatus)
}
@@ -0,0 +1,16 @@
import XCTest
@testable import HaptikaSafety
final class PPGThermalSafetyPolicyTests: XCTestCase {
func testSeriousAndCriticalStatesStopMeasurement() {
XCTAssertTrue(PPGThermalSafetyPolicy.shouldStopMeasurement(forStateName: "serious"))
XCTAssertTrue(PPGThermalSafetyPolicy.shouldStopMeasurement(forStateName: "critical"))
}
func testNominalFairAndUnknownStatesRemainAvailable() {
XCTAssertFalse(PPGThermalSafetyPolicy.shouldStopMeasurement(forStateName: "nominal"))
XCTAssertFalse(PPGThermalSafetyPolicy.shouldStopMeasurement(forStateName: "fair"))
XCTAssertFalse(PPGThermalSafetyPolicy.shouldStopMeasurement(forStateName: "unknown"))
XCTAssertFalse(PPGThermalSafetyPolicy.shouldStopMeasurement(forStateName: "future-state"))
}
}
@@ -0,0 +1,52 @@
import XCTest
@testable import HaptikaSignal
final class PPGCoherencePolicyTests: XCTestCase {
func testSessionDurationLimitsRemainPurposeSpecific() {
XCTAssertEqual(PPGCoherencePolicy.maximumDurationSeconds(isCoherence: false), 120)
XCTAssertEqual(PPGCoherencePolicy.maximumDurationSeconds(isCoherence: true), 300)
XCTAssertEqual(PPGCoherencePolicy.coherenceBufferDurationSeconds, 75)
}
func testLiveTrackerPublishesEveryNewBeatOnlyOnce() {
var tracker = PPGLiveBeatTracker()
let first = tracker.ingest([
interval(end: 1, duration: 800), interval(end: 1.8, duration: 800),
])
XCTAssertEqual(first.map(\.sequence), [1, 2])
let overlapping = tracker.ingest([
interval(end: 1.82, duration: 800), interval(end: 2.6, duration: 780),
])
XCTAssertEqual(overlapping.map(\.sequence), [3])
XCTAssertEqual(overlapping.first?.timestampSeconds, 2.6)
}
func testTrackerDoesNotMoveBackwardAndResetsCleanly() {
var tracker = PPGLiveBeatTracker()
_ = tracker.ingest([interval(end: 10, duration: 700)])
XCTAssertTrue(tracker.ingest([interval(end: 9.9, duration: 700)]).isEmpty)
tracker.reset()
XCTAssertEqual(tracker.ingest([interval(end: 1, duration: 700)]).first?.sequence, 1)
}
func testTrackerRejectsInvalidAndRejectedIntervals() {
var tracker = PPGLiveBeatTracker()
let rejected = PulseInterval(
sourceIndex: 0, startSeconds: 0, endSeconds: 1,
durationMilliseconds: 800, rejectionReason: .localOutlier, qualityScore: 1
)
XCTAssertTrue(tracker.ingest([
rejected, interval(end: .nan, duration: 800), interval(end: 2, duration: 2_500),
]).isEmpty)
}
private func interval(end: Double, duration: Double) -> PulseInterval {
PulseInterval(
sourceIndex: 0,
startSeconds: end - duration / 1_000,
endSeconds: end,
durationMilliseconds: duration,
qualityScore: 0.9
)
}
}
@@ -0,0 +1,247 @@
import XCTest
@testable import HaptikaSignal
final class PPGSignalPipelineTests: XCTestCase {
func testPRVCalculatorDerivesIntervalMetrics() throws {
let intervals = [700.0, 720, 760, 710, 800, 730, 750, 705]
let metrics = try PPGPRVCalculator.calculate(
intervalsMilliseconds: intervals,
configuration: PPGProcessingConfiguration(minimumIntervalsForPRV: 5)
)
XCTAssertEqual(metrics.meanIpiMs ?? 0, 734.375, accuracy: 0.001)
XCTAssertEqual(metrics.medianIpiMs ?? 0, 725, accuracy: 0.001)
XCTAssertEqual(metrics.pnn50Percent ?? 0, 200.0 / 7.0, accuracy: 0.001)
XCTAssertEqual(metrics.rangeIpiMs ?? 0, 100, accuracy: 0.001)
XCTAssertEqual(metrics.modeIpiMs ?? 0, 713, accuracy: 0.001)
XCTAssertEqual(metrics.amo50Percent ?? 0, 62.5, accuracy: 0.001)
XCTAssertEqual((metrics.coefficientOfVariation ?? 0) * 100, 4.68, accuracy: 0.1)
}
func testBPMEstimatorCorrectsSecondHarmonic() throws {
let sampleRate = 30.0
let duration = 30.0
let sampleCount = Int(sampleRate * duration)
let timestamps = (0..<sampleCount).map { Double($0) / sampleRate }
// The spectral method sees a strong 144 BPM harmonic while the clean peak
// intervals correctly describe a 72 BPM pulse.
let signal = timestamps.map { sin(2 * Double.pi * 2.4 * $0) }
let peakInterval = 60.0 / 72.0
let peaks = (0..<35).map { index in
DetectedPeak(
sampleIndex: Int((Double(index) * peakInterval * sampleRate).rounded()),
timestampSeconds: Double(index) * peakInterval,
amplitude: 1,
prominence: 1
)
}
let intervals = zip(peaks, peaks.dropFirst()).enumerated().map { index, pair in
PulseInterval(
sourceIndex: index,
startSeconds: pair.0.timestampSeconds,
endSeconds: pair.1.timestampSeconds,
durationMilliseconds: peakInterval * 1_000
)
}
let detection = PeakDetectionResult(
peaks: peaks,
intervals: intervals,
selectedPolarity: 1,
confidence: 0.9
)
let estimate = try PPGBPMEstimator.estimate(
signal: signal,
timestampsSeconds: timestamps,
validityMask: Array(repeating: true, count: sampleCount),
peakDetection: detection,
selectedChannel: .greenRedRatio,
configuration: .production
)
XCTAssertNotNil(estimate.bpm)
XCTAssertEqual(estimate.bpm ?? 0, 72, accuracy: 1.5)
XCTAssertEqual(estimate.frequencyBpm ?? 0, 72, accuracy: 1.5)
}
func testProcessorProducesHeartRateFromCleanContactPPG() throws {
let sampleRate = 30.0
let duration = 55.0
let sampleCount = Int(sampleRate * duration) + 1
let samples = (0..<sampleCount).map { index -> PPGFrameSample in
let time = Double(index) / sampleRate
let pulse = sin(2 * Double.pi * 1.2 * time)
+ 0.18 * sin(2 * Double.pi * 2.4 * time)
let slowLightDrift = 1 + 0.08 * sin(2 * Double.pi * 0.08 * time)
return PPGFrameSample(
timestampSeconds: time,
red: 185 * slowLightDrift + 2.5 * pulse,
green: 82 * slowLightDrift + 5.5 * pulse,
luminance: 112 * slowLightDrift + 4 * pulse,
contactConfidence: 0.9,
saturationRatio: 0,
motionArtifactScore: 0.02
)
}
let configuration = PPGProcessingConfiguration(
minimumAnalysisDurationSeconds: 45,
filterTapCount: 61
)
let result = try PPGSignalProcessor(configuration: configuration)
.analyze(samples: samples)
XCTAssertEqual(result.status, .valid)
XCTAssertNotNil(result.bpm.bpm)
XCTAssertEqual(result.bpm.bpm ?? 0, 72, accuracy: 3)
XCTAssertGreaterThan(result.quality.score, 0.65)
}
func testResamplerKeepsUniformGridAndMasksLongGap() throws {
let input = [
TimestampedValue(timestampSeconds: 0.00, value: 0),
TimestampedValue(timestampSeconds: 0.09, value: 1),
TimestampedValue(timestampSeconds: 0.21, value: 2),
TimestampedValue(timestampSeconds: 0.80, value: 3),
TimestampedValue(timestampSeconds: 0.91, value: 4),
]
let result = try PPGSignalResampler.resample(
input,
targetSampleRateHz: 10,
maximumInterpolationGapSeconds: 0.20
)
XCTAssertEqual(result.timestampsSeconds.count, result.values.count)
XCTAssertEqual(result.timestampsSeconds[1] - result.timestampsSeconds[0], 0.1, accuracy: 1e-9)
XCTAssertFalse(result.validityMask[4], "A long camera gap must remain unusable")
XCTAssertGreaterThan(result.diagnostics.droppedFrameRatio, 0)
}
func testPRVLocalFilterRejectsArtifactWithoutRejectingGradualChange() throws {
let intervals = [700.0, 710, 720, 730, 740, 400, 760, 770, 780, 790, 800]
let result = try PPGPRVCalculator.calculate(
intervals: makeIntervals(intervals),
configuration: PPGProcessingConfiguration(
minimumIntervalsForPRV: 5,
maximumRejectedIntervalRatioForPRV: 0.25
)
)
XCTAssertEqual(result.intervals[5].rejectionReason, .localOutlier)
XCTAssertTrue(result.intervals[10].isAccepted, "A gradual HR change must remain usable")
XCTAssertEqual(result.metrics.validIntervalCount, intervals.count - 1)
XCTAssertTrue(result.metrics.reliable)
}
func testProcessorCanRecoverPulseCarriedByBlueChrominance() throws {
let sampleRate = 30.0
let duration = 55.0
let samples = (0...Int(sampleRate * duration)).map { index -> PPGFrameSample in
let time = Double(index) / sampleRate
let pulse = sin(2 * Double.pi * 1.2 * time)
+ 0.20 * sin(2 * Double.pi * 2.4 * time)
let red = 150.0
let green = 100.0
let blue = 80 + 9 * pulse
let luminance = 0.2126 * red + 0.7152 * green + 0.0722 * blue
return PPGFrameSample(
timestampSeconds: time,
red: red,
green: green,
luminance: luminance,
contactConfidence: 0.95,
saturationRatio: 0,
motionArtifactScore: 0
)
}
let result = try PPGSignalProcessor(
configuration: PPGProcessingConfiguration(
minimumAnalysisDurationSeconds: 45,
filterTapCount: 61
)
).analyze(samples: samples)
XCTAssertEqual(result.status, .valid)
XCTAssertTrue(
[.blue, .greenRedRatio, .blueRedRatio, .normalizedBlend]
.contains(result.bpm.selectedChannel)
)
XCTAssertEqual(result.bpm.bpm ?? 0, 72, accuracy: 3)
}
func testCoachingContactScoreDoesNotDiscardARealPeriodicSignal() throws {
let sampleRate = 30.0
let duration = 55.0
let samples = (0...Int(sampleRate * duration)).map { index -> PPGFrameSample in
let time = Double(index) / sampleRate
let pulse = sin(2 * Double.pi * 1.25 * time)
+ 0.16 * sin(2 * Double.pi * 2.5 * time)
return PPGFrameSample(
timestampSeconds: time,
red: 142 + 3.2 * pulse,
green: 104 + 5.8 * pulse,
blue: 76 + 1.4 * pulse,
luminance: 111 + 4.9 * pulse,
contactConfidence: 0.10,
saturationRatio: 0,
motionArtifactScore: 0.02
)
}
let result = try PPGSignalProcessor(
configuration: PPGProcessingConfiguration(
minimumAnalysisDurationSeconds: 45,
filterTapCount: 61
)
).analyze(samples: samples)
XCTAssertEqual(result.status, .valid)
XCTAssertEqual(result.bpm.bpm ?? 0, 75, accuracy: 3)
XCTAssertTrue(result.quality.issues.contains(.poorFingerContact))
}
func testBeatMorphologyScoresDistortedCycleWithoutLosingCleanBeats() throws {
let sampleRate = 30.0
let periodSamples = 25
let sampleCount = periodSamples * 14 + 1
var signal = (0..<sampleCount).map { index in
cos(2 * Double.pi * Double(index % periodSamples) / Double(periodSamples))
}
// Preserve the two bounding pulse peaks but flatten one complete cycle.
for index in (periodSamples * 6 + 1)..<(periodSamples * 7) {
signal[index] = -0.15
}
let timestamps = (0..<sampleCount).map { Double($0) / sampleRate }
let result = try PPGPeakDetector.detect(
signal: signal,
timestampsSeconds: timestamps,
validityMask: Array(repeating: true, count: sampleCount),
configuration: PPGProcessingConfiguration(
minimumAnalysisDurationSeconds: 1,
minimumPeakProminence: 0.10,
minimumPeakAmplitude: 0.05
)
)
let scores = result.intervals.map(\.qualityScore)
XCTAssertLessThan(
scores.min() ?? 1,
SignalStatistics.median(scores) - 0.05,
"The flattened pulse must receive a distinctly lower technical quality"
)
XCTAssertGreaterThan(result.acceptedIntervals.count, 8)
}
private func makeIntervals(_ durations: [Double]) -> [PulseInterval] {
var elapsed = 0.0
return durations.enumerated().map { index, duration in
defer { elapsed += duration / 1_000 }
return PulseInterval(
sourceIndex: index,
startSeconds: elapsed,
endSeconds: elapsed + duration / 1_000,
durationMilliseconds: duration
)
}
}
}
@@ -0,0 +1,202 @@
import XCTest
@testable import HaptikaSignal
final class PPGSyntheticSignalTests: XCTestCase {
private let processorConfiguration = PPGProcessingConfiguration(
minimumAnalysisDurationSeconds: 45,
filterTapCount: 61
)
func testPublicationThresholdsRemainOrderedByScientificDemand() {
XCTAssertEqual(PPGPublicationThresholds.bpmQualityScore, 0.45)
XCTAssertEqual(PPGPublicationThresholds.potentialIndexQualityScore, 0.65)
XCTAssertEqual(PPGPublicationThresholds.prvQualityScore, 0.80)
XCTAssertLessThan(
PPGPublicationThresholds.bpmQualityScore,
PPGPublicationThresholds.potentialIndexQualityScore
)
XCTAssertLessThan(
PPGPublicationThresholds.potentialIndexQualityScore,
PPGPublicationThresholds.prvQualityScore
)
}
func testGeneratorIsDeterministicAndAppliesAllConfiguredArtifacts() throws {
let configuration = PPGSyntheticSignalConfiguration(
durationSeconds: 10,
startingBPM: 72,
intervalVariationMilliseconds: [-20, 20],
noiseAmplitude: 1.5,
driftAmplitude: 0.05,
movementWindows: [.init(startSeconds: 2, endSeconds: 3, intensity: 0.8)],
saturationWindows: [.init(startSeconds: 5, endSeconds: 6)],
dropEveryNthFrame: 10,
missingBeatIndices: [3],
falsePeakTimesSeconds: [7.25],
seed: 42
)
let first = try PPGSyntheticSignalGenerator.generate(configuration: configuration)
let second = try PPGSyntheticSignalGenerator.generate(configuration: configuration)
XCTAssertEqual(first, second)
XCTAssertLessThan(first.samples.count, 301)
XCTAssertTrue(first.samples.contains { $0.motionArtifactScore >= 0.8 })
XCTAssertTrue(first.samples.contains { $0.saturationRatio == 1 })
XCTAssertEqual(first.nominalIntervalsMilliseconds[0], 60_000 / 72 - 20, accuracy: 0.001)
XCTAssertEqual(first.nominalIntervalsMilliseconds[1], 60_000 / 72 + 20, accuracy: 0.001)
}
func testEndToEndCleanSignalsAt60_75And120BPM() throws {
let processor = try PPGSignalProcessor(configuration: processorConfiguration)
for expectedBPM in [60.0, 75.0, 120.0] {
let fixture = try PPGSyntheticSignalGenerator.generate(
configuration: PPGSyntheticSignalConfiguration(
durationSeconds: 60,
startingBPM: expectedBPM,
noiseAmplitude: 0.25,
driftAmplitude: 0.04,
seed: UInt64(expectedBPM)
)
)
let result = try processor.analyze(samples: fixture.samples)
XCTAssertEqual(result.status, .valid, "Expected a valid \(expectedBPM) BPM fixture")
XCTAssertEqual(result.bpm.bpm ?? 0, expectedBPM, accuracy: 3)
XCTAssertFalse(result.quality.issues.contains(.nonFiniteInput))
}
}
func testModerateNoiseDriftAndDroppedFramesRemainAnalyzable() throws {
let fixture = try PPGSyntheticSignalGenerator.generate(
configuration: PPGSyntheticSignalConfiguration(
durationSeconds: 60,
startingBPM: 78,
noiseAmplitude: 1.2,
driftAmplitude: 0.10,
dropEveryNthFrame: 20,
seed: 77
)
)
let result = try PPGSignalProcessor(configuration: processorConfiguration)
.analyze(samples: fixture.samples)
XCTAssertEqual(result.bpm.bpm ?? 0, 78, accuracy: 4)
XCTAssertGreaterThan(result.timebase.droppedFrameRatio, 0)
}
func testMovementAndSaturationAreReportedInsteadOfProducingSilentPRV() throws {
let fixture = try PPGSyntheticSignalGenerator.generate(
configuration: PPGSyntheticSignalConfiguration(
durationSeconds: 60,
startingBPM: 72,
noiseAmplitude: 0.5,
movementWindows: [.init(startSeconds: 10, endSeconds: 45, intensity: 1)],
saturationWindows: [.init(startSeconds: 8, endSeconds: 52, intensity: 1)],
seed: 91
)
)
let result = try PPGSignalProcessor(configuration: processorConfiguration)
.analyze(samples: fixture.samples)
XCTAssertTrue(result.quality.issues.contains(.motionArtifacts))
XCTAssertTrue(result.quality.issues.contains(.excessiveSaturation))
XCTAssertFalse(result.prv.reliable)
}
func testMissingAndFalsePeaksDoNotCreateNonFiniteMetrics() throws {
let fixture = try PPGSyntheticSignalGenerator.generate(
configuration: PPGSyntheticSignalConfiguration(
durationSeconds: 60,
startingBPM: 75,
noiseAmplitude: 0.4,
missingBeatIndices: [8, 22, 41],
falsePeakTimesSeconds: [12.30, 31.15, 46.40],
seed: 121
)
)
let result = try PPGSignalProcessor(configuration: processorConfiguration)
.analyze(samples: fixture.samples)
XCTAssertTrue(result.bpm.confidence.isFinite)
XCTAssertTrue(result.quality.score.isFinite)
XCTAssertTrue(result.intervals.allSatisfy { $0.durationMilliseconds.isFinite })
XCTAssertGreaterThan(result.bpm.rejectedPeakCount + result.prv.rejectedIntervalCount, 0)
}
func testProgressiveBPMChangeStaysInsideGeneratedRange() throws {
let fixture = try PPGSyntheticSignalGenerator.generate(
configuration: PPGSyntheticSignalConfiguration(
durationSeconds: 60,
startingBPM: 60,
endingBPM: 90,
noiseAmplitude: 0.2,
seed: 3
)
)
let result = try PPGSignalProcessor(configuration: processorConfiguration)
.analyze(samples: fixture.samples)
XCTAssertNotNil(result.bpm.bpm)
XCTAssertTrue((60...90).contains(result.bpm.bpm ?? 0))
XCTAssertGreaterThan(fixture.nominalIntervalsMilliseconds.first ?? 0, 950)
XCTAssertLessThan(fixture.nominalIntervalsMilliseconds.last ?? 1_000, 700)
}
func testKnownIntervalVariabilityProducesExpectedRMSSD() throws {
let offsets = [-30.0, 30.0]
let fixture = try PPGSyntheticSignalGenerator.generate(
configuration: PPGSyntheticSignalConfiguration(
durationSeconds: 60,
startingBPM: 75,
intervalVariationMilliseconds: offsets
)
)
let metrics = try PPGPRVCalculator.calculate(
intervalsMilliseconds: Array(fixture.nominalIntervalsMilliseconds.prefix(60)),
configuration: PPGProcessingConfiguration(minimumIntervalsForPRV: 20)
)
XCTAssertEqual(metrics.estimatedRmssdMs ?? 0, 60, accuracy: 0.001)
XCTAssertTrue(metrics.reliable)
}
func testNaNAndInfinityFramesAreDiscardedExhaustively() throws {
var fixture = try PPGSyntheticSignalGenerator.generate(
configuration: PPGSyntheticSignalConfiguration(durationSeconds: 60, startingBPM: 72)
)
fixture.samples.insert(
PPGFrameSample(timestampSeconds: .nan, red: 1, green: 1, luminance: 1),
at: 20
)
fixture.samples.insert(
PPGFrameSample(timestampSeconds: 2, red: .infinity, green: 1, luminance: 1),
at: 40
)
fixture.samples.insert(
PPGFrameSample(timestampSeconds: 3, red: 1, green: -.infinity, luminance: 1),
at: 60
)
fixture.samples.insert(
PPGFrameSample(timestampSeconds: 4, red: 1, green: 1, luminance: .nan),
at: 80
)
let result = try PPGSignalProcessor(configuration: processorConfiguration)
.analyze(samples: fixture.samples)
XCTAssertEqual(result.timebase.discardedSampleCount, 4)
XCTAssertTrue(result.quality.issues.contains(.nonFiniteInput))
XCTAssertEqual(result.bpm.bpm ?? 0, 72, accuracy: 3)
}
func testGeneratorRejectsUnsafeConfiguration() {
XCTAssertThrowsError(
try PPGSyntheticSignalGenerator.generate(
configuration: PPGSyntheticSignalConfiguration(sampleRateHz: .nan)
)
)
XCTAssertThrowsError(
try PPGSyntheticSignalGenerator.generate(
configuration: PPGSyntheticSignalConfiguration(dropEveryNthFrame: 1)
)
)
}
}