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