74 lines
2.7 KiB
Swift
74 lines
2.7 KiB
Swift
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
|
|
)
|
|
}
|
|
}
|