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