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