feat: implement Haptika PPG and Watch V1
This commit is contained in:
@@ -0,0 +1,73 @@
|
||||
import Foundation
|
||||
|
||||
public enum WatchMeasurementInvalidReason: String, Codable, CaseIterable, Sendable {
|
||||
case noValidHeartRate
|
||||
case insufficientSamples
|
||||
case insufficientCoverage
|
||||
case staleData
|
||||
case interrupted
|
||||
}
|
||||
|
||||
public struct WatchMeasurementValidation: Equatable, Sendable {
|
||||
public let validSamples: [WatchHeartRateSampleV1]
|
||||
public let currentBPM: Double?
|
||||
public let averageBPM: Double?
|
||||
public let minimumBPM: Double?
|
||||
public let maximumBPM: Double?
|
||||
public let coverageSeconds: TimeInterval
|
||||
public let maximumGapSeconds: TimeInterval
|
||||
public let quality: Double
|
||||
public let invalidReasons: [WatchMeasurementInvalidReason]
|
||||
|
||||
public var isValid: Bool { invalidReasons.isEmpty }
|
||||
}
|
||||
|
||||
public enum WatchMeasurementValidator {
|
||||
public static let minimumBPM = 35.0
|
||||
public static let maximumBPM = 220.0
|
||||
public static let minimumSamples = 8
|
||||
public static let minimumCoverageSeconds = 45.0
|
||||
public static let maximumSilenceSeconds = 15.0
|
||||
public static let targetDurationSeconds = 60.0
|
||||
|
||||
public static func validate(
|
||||
samples: [WatchHeartRateSampleV1],
|
||||
interrupted: Bool = false
|
||||
) -> WatchMeasurementValidation {
|
||||
let filtered = samples
|
||||
.filter { $0.bpm.isFinite && (minimumBPM ... maximumBPM).contains($0.bpm) }
|
||||
.sorted { $0.date < $1.date }
|
||||
|
||||
let coverage = max(0, (filtered.last?.date.timeIntervalSince(filtered.first?.date ?? Date())) ?? 0)
|
||||
let gaps = zip(filtered, filtered.dropFirst()).map { $1.date.timeIntervalSince($0.date) }
|
||||
let maximumGap = max(0, gaps.max() ?? 0)
|
||||
let bpms = filtered.map(\.bpm)
|
||||
let average = bpms.isEmpty ? nil : bpms.reduce(0, +) / Double(bpms.count)
|
||||
|
||||
var reasons: [WatchMeasurementInvalidReason] = []
|
||||
if filtered.isEmpty { reasons.append(.noValidHeartRate) }
|
||||
if filtered.count < minimumSamples { reasons.append(.insufficientSamples) }
|
||||
if coverage < minimumCoverageSeconds { reasons.append(.insufficientCoverage) }
|
||||
if maximumGap > maximumSilenceSeconds { reasons.append(.staleData) }
|
||||
if interrupted { reasons.append(.interrupted) }
|
||||
|
||||
let sampleScore = min(1, Double(filtered.count) / Double(minimumSamples))
|
||||
let coverageScore = min(1, coverage / targetDurationSeconds)
|
||||
let continuityScore = maximumGap <= maximumSilenceSeconds
|
||||
? 1
|
||||
: max(0, maximumSilenceSeconds / maximumGap)
|
||||
let quality = min(1, max(0, sampleScore * coverageScore * continuityScore))
|
||||
|
||||
return WatchMeasurementValidation(
|
||||
validSamples: filtered,
|
||||
currentBPM: filtered.last?.bpm,
|
||||
averageBPM: average,
|
||||
minimumBPM: bpms.min(),
|
||||
maximumBPM: bpms.max(),
|
||||
coverageSeconds: coverage,
|
||||
maximumGapSeconds: maximumGap,
|
||||
quality: quality,
|
||||
invalidReasons: reasons
|
||||
)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user