Files
2026-07-31 16:20:10 +02:00

203 lines
7.5 KiB
Swift

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)
)
)
}
}