feat: implement Haptika PPG and Watch V1
This commit is contained in:
+52
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
+247
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
+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