Files
haptika/ios/HaptikaWatch/Core/WatchMeasurementValidator.swift
2026-07-31 16:20:10 +02:00

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