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

248 lines
8.8 KiB
Swift

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