feat: implement Haptika PPG and Watch V1
This commit is contained in:
@@ -0,0 +1,523 @@
|
||||
import Foundation
|
||||
|
||||
public enum PPGSignalError: Error, Equatable, Sendable {
|
||||
case invalidConfiguration(String)
|
||||
case insufficientSamples(required: Int, actual: Int)
|
||||
case insufficientDuration(requiredSeconds: Double, actualSeconds: Double)
|
||||
case invalidTimestamps
|
||||
case noUsableChannel
|
||||
}
|
||||
|
||||
/// Compact numeric output expected from the camera frame extractor.
|
||||
/// No image data is retained by the signal-processing core.
|
||||
public struct PPGFrameSample: Codable, Equatable, Sendable {
|
||||
public var timestampSeconds: Double
|
||||
public var red: Double
|
||||
public var green: Double
|
||||
public var blue: Double
|
||||
public var luminance: Double
|
||||
public var contactConfidence: Double
|
||||
public var saturationRatio: Double
|
||||
public var motionArtifactScore: Double
|
||||
|
||||
public init(
|
||||
timestampSeconds: Double,
|
||||
red: Double,
|
||||
green: Double,
|
||||
blue: Double? = nil,
|
||||
luminance: Double,
|
||||
contactConfidence: Double = 1,
|
||||
saturationRatio: Double = 0,
|
||||
motionArtifactScore: Double = 0
|
||||
) {
|
||||
self.timestampSeconds = timestampSeconds
|
||||
self.red = red
|
||||
self.green = green
|
||||
let reconstructedBlue = (luminance - 0.2126 * red - 0.7152 * green) / 0.0722
|
||||
self.blue = blue ?? min(max(reconstructedBlue.isFinite ? reconstructedBlue : luminance, 0), 255)
|
||||
self.luminance = luminance
|
||||
self.contactConfidence = Self.clamp01(contactConfidence)
|
||||
self.saturationRatio = Self.clamp01(saturationRatio)
|
||||
self.motionArtifactScore = Self.clamp01(motionArtifactScore)
|
||||
}
|
||||
|
||||
public var hasFiniteSignalValues: Bool {
|
||||
timestampSeconds.isFinite && red.isFinite && green.isFinite
|
||||
&& blue.isFinite && luminance.isFinite
|
||||
}
|
||||
|
||||
private static func clamp01(_ value: Double) -> Double {
|
||||
guard value.isFinite else { return 0 }
|
||||
return min(max(value, 0), 1)
|
||||
}
|
||||
}
|
||||
|
||||
public enum PPGSignalChannel: String, CaseIterable, Codable, Equatable, Sendable {
|
||||
case red
|
||||
case green
|
||||
case blue
|
||||
case luminance
|
||||
case greenRedRatio
|
||||
case blueRedRatio
|
||||
case normalizedBlend
|
||||
}
|
||||
|
||||
/// Publication gates for the three product decisions made from one camera capture.
|
||||
///
|
||||
/// These thresholds deliberately do not mean the same thing:
|
||||
/// - mean BPM can be published from a technically usable periodic signal;
|
||||
/// - beat-to-beat PRV needs substantially better timing quality;
|
||||
/// - the potential-activation index is gated again by the product layer and a
|
||||
/// personalized baseline. It must never be presented as a medical stress measure.
|
||||
///
|
||||
/// Keeping the values named here prevents a permissive live-preview threshold from
|
||||
/// accidentally becoming the PRV or product-index threshold.
|
||||
public enum PPGPublicationThresholds {
|
||||
/// Minimum final technical score for a definitive mean BPM.
|
||||
public static let bpmQualityScore = 0.45
|
||||
/// Minimum temporal/frequency estimator confidence for a definitive mean BPM.
|
||||
public static let bpmConfidence = 0.40
|
||||
/// Minimum final technical score for beat-to-beat PRV to be marked reliable.
|
||||
public static let prvQualityScore = 0.80
|
||||
/// Minimum estimator confidence for beat-to-beat PRV to be marked reliable.
|
||||
public static let prvBpmConfidence = 0.70
|
||||
/// Product-layer signal gate used before calculating the potential index.
|
||||
public static let potentialIndexQualityScore = 0.65
|
||||
/// Product-layer minimum number of accepted pulse intervals.
|
||||
public static let potentialIndexMinimumIntervals = 20
|
||||
}
|
||||
|
||||
public struct PPGProcessingConfiguration: Codable, Equatable, Sendable {
|
||||
public var targetSampleRateHz: Double
|
||||
public var minimumFrequencyHz: Double
|
||||
public var maximumFrequencyHz: Double
|
||||
public var minimumAnalysisDurationSeconds: Double
|
||||
public var maximumInterpolationGapSeconds: Double
|
||||
public var longGapLimitSeconds: Double
|
||||
public var minimumContactConfidence: Double
|
||||
public var maximumSaturationRatio: Double
|
||||
public var maximumMotionArtifactScore: Double
|
||||
public var minimumPeakProminence: Double
|
||||
public var minimumPeakAmplitude: Double
|
||||
public var refractoryPeriodSeconds: Double
|
||||
public var maximumMethodDisagreementBPM: Double
|
||||
public var minimumQualityScore: Double
|
||||
public var minimumIntervalsForPRV: Int
|
||||
public var maximumRejectedIntervalRatioForPRV: Double
|
||||
public var filterTapCount: Int?
|
||||
|
||||
public init(
|
||||
targetSampleRateHz: Double = 30,
|
||||
minimumFrequencyHz: Double = 0.7,
|
||||
maximumFrequencyHz: Double = 3.5,
|
||||
minimumAnalysisDurationSeconds: Double = 45,
|
||||
maximumInterpolationGapSeconds: Double = 0.20,
|
||||
longGapLimitSeconds: Double = 0.80,
|
||||
minimumContactConfidence: Double = 0.55,
|
||||
maximumSaturationRatio: Double = 0.70,
|
||||
maximumMotionArtifactScore: Double = 0.65,
|
||||
minimumPeakProminence: Double = 0.35,
|
||||
minimumPeakAmplitude: Double = 0.10,
|
||||
refractoryPeriodSeconds: Double = 0.25,
|
||||
maximumMethodDisagreementBPM: Double = 8,
|
||||
minimumQualityScore: Double = PPGPublicationThresholds.potentialIndexQualityScore,
|
||||
minimumIntervalsForPRV: Int = 20,
|
||||
maximumRejectedIntervalRatioForPRV: Double = 0.20,
|
||||
filterTapCount: Int? = nil
|
||||
) {
|
||||
self.targetSampleRateHz = targetSampleRateHz
|
||||
self.minimumFrequencyHz = minimumFrequencyHz
|
||||
self.maximumFrequencyHz = maximumFrequencyHz
|
||||
self.minimumAnalysisDurationSeconds = minimumAnalysisDurationSeconds
|
||||
self.maximumInterpolationGapSeconds = maximumInterpolationGapSeconds
|
||||
self.longGapLimitSeconds = longGapLimitSeconds
|
||||
self.minimumContactConfidence = Self.clamp01(minimumContactConfidence)
|
||||
self.maximumSaturationRatio = Self.clamp01(maximumSaturationRatio)
|
||||
self.maximumMotionArtifactScore = Self.clamp01(maximumMotionArtifactScore)
|
||||
self.minimumPeakProminence = max(minimumPeakProminence, 0)
|
||||
self.minimumPeakAmplitude = max(minimumPeakAmplitude, 0)
|
||||
self.refractoryPeriodSeconds = max(refractoryPeriodSeconds, 0)
|
||||
self.maximumMethodDisagreementBPM = max(maximumMethodDisagreementBPM, 0)
|
||||
self.minimumQualityScore = Self.clamp01(minimumQualityScore)
|
||||
self.minimumIntervalsForPRV = max(minimumIntervalsForPRV, 2)
|
||||
self.maximumRejectedIntervalRatioForPRV = Self.clamp01(maximumRejectedIntervalRatioForPRV)
|
||||
self.filterTapCount = filterTapCount
|
||||
}
|
||||
|
||||
public static let production = PPGProcessingConfiguration()
|
||||
|
||||
public func validated() throws -> Self {
|
||||
guard targetSampleRateHz.isFinite, targetSampleRateHz >= 10 else {
|
||||
throw PPGSignalError.invalidConfiguration("targetSampleRateHz must be at least 10 Hz")
|
||||
}
|
||||
guard minimumFrequencyHz.isFinite,
|
||||
maximumFrequencyHz.isFinite,
|
||||
minimumFrequencyHz > 0,
|
||||
maximumFrequencyHz > minimumFrequencyHz,
|
||||
maximumFrequencyHz < targetSampleRateHz / 2
|
||||
else {
|
||||
throw PPGSignalError.invalidConfiguration("frequency band must be inside (0, Nyquist)")
|
||||
}
|
||||
guard minimumAnalysisDurationSeconds.isFinite, minimumAnalysisDurationSeconds > 0 else {
|
||||
throw PPGSignalError.invalidConfiguration("minimumAnalysisDurationSeconds must be positive")
|
||||
}
|
||||
guard maximumInterpolationGapSeconds.isFinite,
|
||||
maximumInterpolationGapSeconds > 0,
|
||||
longGapLimitSeconds.isFinite,
|
||||
longGapLimitSeconds >= maximumInterpolationGapSeconds
|
||||
else {
|
||||
throw PPGSignalError.invalidConfiguration("gap limits are inconsistent")
|
||||
}
|
||||
if let filterTapCount, filterTapCount < 5 {
|
||||
throw PPGSignalError.invalidConfiguration("filterTapCount must be at least 5")
|
||||
}
|
||||
return self
|
||||
}
|
||||
|
||||
private static func clamp01(_ value: Double) -> Double {
|
||||
guard value.isFinite else { return 0 }
|
||||
return min(max(value, 0), 1)
|
||||
}
|
||||
}
|
||||
|
||||
public struct TimestampedValue: Equatable, Sendable {
|
||||
public var timestampSeconds: Double
|
||||
public var value: Double
|
||||
|
||||
public init(timestampSeconds: Double, value: Double) {
|
||||
self.timestampSeconds = timestampSeconds
|
||||
self.value = value
|
||||
}
|
||||
}
|
||||
|
||||
public struct TimebaseDiagnostics: Codable, Equatable, Sendable {
|
||||
public var inputSampleCount: Int
|
||||
public var usableInputSampleCount: Int
|
||||
public var discardedSampleCount: Int
|
||||
public var duplicateTimestampCount: Int
|
||||
public var outOfOrderTimestampCount: Int
|
||||
public var durationSeconds: Double
|
||||
public var usableDurationSeconds: Double
|
||||
public var estimatedSampleRateHz: Double
|
||||
public var frameIntervalMADSeconds: Double
|
||||
public var droppedFrameRatio: Double
|
||||
public var maximumGapSeconds: Double
|
||||
public var longGapDurationSeconds: Double
|
||||
|
||||
public init(
|
||||
inputSampleCount: Int,
|
||||
usableInputSampleCount: Int,
|
||||
discardedSampleCount: Int,
|
||||
duplicateTimestampCount: Int,
|
||||
outOfOrderTimestampCount: Int,
|
||||
durationSeconds: Double,
|
||||
usableDurationSeconds: Double,
|
||||
estimatedSampleRateHz: Double,
|
||||
frameIntervalMADSeconds: Double,
|
||||
droppedFrameRatio: Double,
|
||||
maximumGapSeconds: Double,
|
||||
longGapDurationSeconds: Double
|
||||
) {
|
||||
self.inputSampleCount = inputSampleCount
|
||||
self.usableInputSampleCount = usableInputSampleCount
|
||||
self.discardedSampleCount = discardedSampleCount
|
||||
self.duplicateTimestampCount = duplicateTimestampCount
|
||||
self.outOfOrderTimestampCount = outOfOrderTimestampCount
|
||||
self.durationSeconds = max(durationSeconds, 0)
|
||||
self.usableDurationSeconds = max(usableDurationSeconds, 0)
|
||||
self.estimatedSampleRateHz = max(estimatedSampleRateHz, 0)
|
||||
self.frameIntervalMADSeconds = max(frameIntervalMADSeconds, 0)
|
||||
self.droppedFrameRatio = min(max(droppedFrameRatio, 0), 1)
|
||||
self.maximumGapSeconds = max(maximumGapSeconds, 0)
|
||||
self.longGapDurationSeconds = max(longGapDurationSeconds, 0)
|
||||
}
|
||||
}
|
||||
|
||||
public struct ResampledSignal: Equatable, Sendable {
|
||||
public var timestampsSeconds: [Double]
|
||||
public var values: [Double]
|
||||
public var validityMask: [Bool]
|
||||
public var diagnostics: TimebaseDiagnostics
|
||||
|
||||
public init(
|
||||
timestampsSeconds: [Double],
|
||||
values: [Double],
|
||||
validityMask: [Bool],
|
||||
diagnostics: TimebaseDiagnostics
|
||||
) {
|
||||
precondition(timestampsSeconds.count == values.count)
|
||||
precondition(values.count == validityMask.count)
|
||||
self.timestampsSeconds = timestampsSeconds
|
||||
self.values = values
|
||||
self.validityMask = validityMask
|
||||
self.diagnostics = diagnostics
|
||||
}
|
||||
}
|
||||
|
||||
public struct DetectedPeak: Codable, Equatable, Sendable {
|
||||
public var sampleIndex: Int
|
||||
public var timestampSeconds: Double
|
||||
public var amplitude: Double
|
||||
public var prominence: Double
|
||||
|
||||
public init(sampleIndex: Int, timestampSeconds: Double, amplitude: Double, prominence: Double) {
|
||||
self.sampleIndex = sampleIndex
|
||||
self.timestampSeconds = timestampSeconds
|
||||
self.amplitude = amplitude
|
||||
self.prominence = max(prominence, 0)
|
||||
}
|
||||
}
|
||||
|
||||
public enum PulseIntervalRejectionReason: String, Codable, Equatable, Sendable {
|
||||
case outsidePhysiologicalRange
|
||||
case localOutlier
|
||||
case invalidSignalRegion
|
||||
case lowMorphologyQuality
|
||||
}
|
||||
|
||||
public struct PulseInterval: Codable, Equatable, Sendable {
|
||||
public var sourceIndex: Int
|
||||
public var startSeconds: Double
|
||||
public var endSeconds: Double
|
||||
public var durationMilliseconds: Double
|
||||
public var rejectionReason: PulseIntervalRejectionReason?
|
||||
/// Per-beat technical quality. This is not a medical confidence score.
|
||||
public var qualityScore: Double
|
||||
|
||||
public init(
|
||||
sourceIndex: Int,
|
||||
startSeconds: Double,
|
||||
endSeconds: Double,
|
||||
durationMilliseconds: Double,
|
||||
rejectionReason: PulseIntervalRejectionReason? = nil,
|
||||
qualityScore: Double = 1
|
||||
) {
|
||||
self.sourceIndex = sourceIndex
|
||||
self.startSeconds = startSeconds
|
||||
self.endSeconds = endSeconds
|
||||
self.durationMilliseconds = durationMilliseconds
|
||||
self.rejectionReason = rejectionReason
|
||||
self.qualityScore = min(max(qualityScore.isFinite ? qualityScore : 0, 0), 1)
|
||||
}
|
||||
|
||||
public var isAccepted: Bool { rejectionReason == nil }
|
||||
}
|
||||
|
||||
public struct PeakDetectionResult: Equatable, Sendable {
|
||||
public var peaks: [DetectedPeak]
|
||||
public var intervals: [PulseInterval]
|
||||
public var selectedPolarity: Int
|
||||
public var confidence: Double
|
||||
|
||||
public init(
|
||||
peaks: [DetectedPeak],
|
||||
intervals: [PulseInterval],
|
||||
selectedPolarity: Int,
|
||||
confidence: Double
|
||||
) {
|
||||
self.peaks = peaks
|
||||
self.intervals = intervals
|
||||
self.selectedPolarity = selectedPolarity >= 0 ? 1 : -1
|
||||
self.confidence = Self.clamp01(confidence)
|
||||
}
|
||||
|
||||
public var acceptedIntervals: [PulseInterval] { intervals.filter(\.isAccepted) }
|
||||
public var rejectedIntervalCount: Int { intervals.count - acceptedIntervals.count }
|
||||
|
||||
private static func clamp01(_ value: Double) -> Double {
|
||||
guard value.isFinite else { return 0 }
|
||||
return min(max(value, 0), 1)
|
||||
}
|
||||
}
|
||||
|
||||
public struct BPMEstimate: Codable, Equatable, Sendable {
|
||||
/// Final BPM. It is nil when the independent temporal and frequency estimates disagree.
|
||||
public var bpm: Double?
|
||||
public var temporalBpm: Double?
|
||||
public var frequencyBpm: Double?
|
||||
public var methodAgreementBpm: Double?
|
||||
public var validPeakCount: Int
|
||||
public var rejectedPeakCount: Int
|
||||
public var confidence: Double
|
||||
public var selectedChannel: PPGSignalChannel
|
||||
public var selectedPolarity: Int
|
||||
|
||||
public init(
|
||||
bpm: Double?,
|
||||
temporalBpm: Double?,
|
||||
frequencyBpm: Double?,
|
||||
methodAgreementBpm: Double?,
|
||||
validPeakCount: Int,
|
||||
rejectedPeakCount: Int,
|
||||
confidence: Double,
|
||||
selectedChannel: PPGSignalChannel,
|
||||
selectedPolarity: Int
|
||||
) {
|
||||
self.bpm = bpm
|
||||
self.temporalBpm = temporalBpm
|
||||
self.frequencyBpm = frequencyBpm
|
||||
self.methodAgreementBpm = methodAgreementBpm
|
||||
self.validPeakCount = max(validPeakCount, 0)
|
||||
self.rejectedPeakCount = max(rejectedPeakCount, 0)
|
||||
self.confidence = Self.clamp01(confidence)
|
||||
self.selectedChannel = selectedChannel
|
||||
self.selectedPolarity = selectedPolarity >= 0 ? 1 : -1
|
||||
}
|
||||
|
||||
private static func clamp01(_ value: Double) -> Double {
|
||||
guard value.isFinite else { return 0 }
|
||||
return min(max(value, 0), 1)
|
||||
}
|
||||
}
|
||||
|
||||
public struct PRVMetrics: Codable, Equatable, Sendable {
|
||||
public var estimatedRmssdMs: Double?
|
||||
public var estimatedSdnnMs: Double?
|
||||
public var meanIpiMs: Double?
|
||||
public var medianIpiMs: Double?
|
||||
public var pnn50Percent: Double?
|
||||
public var rangeIpiMs: Double?
|
||||
public var modeIpiMs: Double?
|
||||
public var amo50Percent: Double?
|
||||
public var coefficientOfVariation: Double?
|
||||
public var validIntervalCount: Int
|
||||
public var rejectedIntervalCount: Int
|
||||
public var rejectedIntervalRatio: Double
|
||||
public var reliable: Bool
|
||||
|
||||
public init(
|
||||
estimatedRmssdMs: Double?,
|
||||
estimatedSdnnMs: Double?,
|
||||
meanIpiMs: Double?,
|
||||
medianIpiMs: Double?,
|
||||
pnn50Percent: Double?,
|
||||
rangeIpiMs: Double?,
|
||||
modeIpiMs: Double?,
|
||||
amo50Percent: Double?,
|
||||
coefficientOfVariation: Double?,
|
||||
validIntervalCount: Int,
|
||||
rejectedIntervalCount: Int,
|
||||
rejectedIntervalRatio: Double,
|
||||
reliable: Bool
|
||||
) {
|
||||
self.estimatedRmssdMs = estimatedRmssdMs
|
||||
self.estimatedSdnnMs = estimatedSdnnMs
|
||||
self.meanIpiMs = meanIpiMs
|
||||
self.medianIpiMs = medianIpiMs
|
||||
self.pnn50Percent = pnn50Percent
|
||||
self.rangeIpiMs = rangeIpiMs
|
||||
self.modeIpiMs = modeIpiMs
|
||||
self.amo50Percent = amo50Percent
|
||||
self.coefficientOfVariation = coefficientOfVariation
|
||||
self.validIntervalCount = max(validIntervalCount, 0)
|
||||
self.rejectedIntervalCount = max(rejectedIntervalCount, 0)
|
||||
self.rejectedIntervalRatio = Self.clamp01(rejectedIntervalRatio)
|
||||
self.reliable = reliable
|
||||
}
|
||||
|
||||
private static func clamp01(_ value: Double) -> Double {
|
||||
guard value.isFinite else { return 1 }
|
||||
return min(max(value, 0), 1)
|
||||
}
|
||||
}
|
||||
|
||||
public enum SignalQualityLevel: String, Codable, Equatable, Sendable {
|
||||
case good
|
||||
case medium
|
||||
case poor
|
||||
case invalid
|
||||
}
|
||||
|
||||
public enum SignalQualityIssue: String, Codable, Equatable, Hashable, Sendable {
|
||||
case insufficientDuration
|
||||
case invalidTimestamps
|
||||
case longInterruption
|
||||
case tooManyDroppedFrames
|
||||
case irregularTimestamps
|
||||
case poorFingerContact
|
||||
case excessiveSaturation
|
||||
case motionArtifacts
|
||||
case lowPulsatileAmplitude
|
||||
case lowSignalToNoise
|
||||
case tooFewPeaks
|
||||
case inconsistentBPMMethods
|
||||
case tooManyRejectedIntervals
|
||||
case unstableChannel
|
||||
case nonFiniteInput
|
||||
}
|
||||
|
||||
public struct SignalQualityResult: Codable, Equatable, Sendable {
|
||||
public var score: Double
|
||||
public var level: SignalQualityLevel
|
||||
public var usableDurationSeconds: Double
|
||||
public var droppedFrameRatio: Double
|
||||
public var saturationRatio: Double
|
||||
public var rejectedIntervalRatio: Double
|
||||
/// Signal-to-residual ratio in decibels around the selected pulse frequency.
|
||||
public var signalToNoiseRatio: Double?
|
||||
public var motionArtifactRatio: Double
|
||||
public var methodAgreementScore: Double
|
||||
public var issues: [SignalQualityIssue]
|
||||
|
||||
public init(
|
||||
score: Double,
|
||||
level: SignalQualityLevel,
|
||||
usableDurationSeconds: Double,
|
||||
droppedFrameRatio: Double,
|
||||
saturationRatio: Double,
|
||||
rejectedIntervalRatio: Double,
|
||||
signalToNoiseRatio: Double?,
|
||||
motionArtifactRatio: Double,
|
||||
methodAgreementScore: Double,
|
||||
issues: [SignalQualityIssue]
|
||||
) {
|
||||
self.score = Self.clamp01(score)
|
||||
self.level = level
|
||||
self.usableDurationSeconds = max(usableDurationSeconds, 0)
|
||||
self.droppedFrameRatio = Self.clamp01(droppedFrameRatio)
|
||||
self.saturationRatio = Self.clamp01(saturationRatio)
|
||||
self.rejectedIntervalRatio = Self.clamp01(rejectedIntervalRatio)
|
||||
self.signalToNoiseRatio = signalToNoiseRatio?.isFinite == true ? signalToNoiseRatio : nil
|
||||
self.motionArtifactRatio = Self.clamp01(motionArtifactRatio)
|
||||
self.methodAgreementScore = Self.clamp01(methodAgreementScore)
|
||||
self.issues = Array(Set(issues)).sorted { $0.rawValue < $1.rawValue }
|
||||
}
|
||||
|
||||
private static func clamp01(_ value: Double) -> Double {
|
||||
guard value.isFinite else { return 0 }
|
||||
return min(max(value, 0), 1)
|
||||
}
|
||||
}
|
||||
|
||||
public enum PPGAnalysisStatus: String, Codable, Equatable, Sendable {
|
||||
case valid
|
||||
case invalid
|
||||
}
|
||||
|
||||
public struct PPGAnalysisResult: Codable, Equatable, Sendable {
|
||||
public var status: PPGAnalysisStatus
|
||||
public var bpm: BPMEstimate
|
||||
public var prv: PRVMetrics
|
||||
public var quality: SignalQualityResult
|
||||
public var peaks: [DetectedPeak]
|
||||
public var intervals: [PulseInterval]
|
||||
public var timebase: TimebaseDiagnostics
|
||||
|
||||
public init(
|
||||
status: PPGAnalysisStatus,
|
||||
bpm: BPMEstimate,
|
||||
prv: PRVMetrics,
|
||||
quality: SignalQualityResult,
|
||||
peaks: [DetectedPeak],
|
||||
intervals: [PulseInterval],
|
||||
timebase: TimebaseDiagnostics
|
||||
) {
|
||||
self.status = status
|
||||
self.bpm = bpm
|
||||
self.prv = prv
|
||||
self.quality = quality
|
||||
self.peaks = peaks
|
||||
self.intervals = intervals
|
||||
self.timebase = timebase
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user