Skip to the content.

Home / Guides

Basic Detection

This guide covers how to configure RepDetectionService for different exercises and motion patterns.

Default Configuration

RepDetectionService ships with defaults that work for many rowing and pulling exercises:

let detector = RepDetectionService()
// detector.calibration is already RepCalibration.default

The default calibration:

Parameter Default Description
alpha 0.2 EMA smoothing factor
thresholdMultiplier 1.5 Multiplied by stddev for dynamic threshold
refractoryPeriod 0.4s Minimum time between reps
detectionAxis .y Which accelerometer axis to analyze
peakPolarity .positive Detect positive or negative peaks
warmupDuration 5.0s Warmup period before detection begins
cooldownDuration 3.0s Cooldown after exercise stops

Tuning Parameters

Detection Axis

Different exercises produce their strongest signal on different axes. The axis depends on watch orientation and exercise movement:

detector.calibration.detectionAxis = .x  // lateral movements
detector.calibration.detectionAxis = .y  // vertical movements (default)
detector.calibration.detectionAxis = .z  // forward/backward movements

Peak Polarity

Some exercises produce a strong positive peak per rep, others a strong negative peak:

detector.calibration.peakPolarity = .positive   // detect upward spikes
detector.calibration.peakPolarity = .negative    // detect downward spikes

Sensitivity

Lower thresholdMultiplier = more sensitive (more detections, more false positives):

detector.calibration.thresholdMultiplier = 1.0  // sensitive
detector.calibration.thresholdMultiplier = 2.0  // conservative

Refractory Period

Minimum time between reps. Set this based on the fastest expected rep speed:

detector.calibration.refractoryPeriod = 0.3  // fast exercises (jump rope)
detector.calibration.refractoryPeriod = 0.8  // slow exercises (deadlift)

Smoothing

Controls how aggressively the filter smooths the raw signal:

detector.calibration.alpha = 0.1  // heavy smoothing, slower response
detector.calibration.alpha = 0.3  // light smoothing, faster response

Warmup Duration

Time to wait before detecting reps. Allows the filter and statistics to stabilize:

detector.calibration.warmupDuration = 3.0   // shorter warmup
detector.calibration.warmupDuration = 0     // no warmup (immediate detection)

Subscribing to Events

Rep Events

detector.repPublisher
    .receive(on: DispatchQueue.main)
    .sink { event in
        repCount += 1
        lastConfidence = event.confidence
    }
    .store(in: &cancellables)

RPM (Reps Per Minute)

Calculated over a rolling 30-second window:

detector.rpmPublisher
    .receive(on: DispatchQueue.main)
    .sink { rpm in
        currentRPM = rpm
    }
    .store(in: &cancellables)

Warmup Countdown

Emits seconds remaining during the warmup period:

detector.warmupPublisher
    .receive(on: DispatchQueue.main)
    .sink { remaining in
        if remaining > 0 {
            statusLabel = "Warmup: \(Int(remaining))s"
        } else {
            statusLabel = "Detecting..."
        }
    }
    .store(in: &cancellables)

Resetting

Call reset() between exercise sets or sessions to clear all internal state:

detector.reset()

This clears the sample buffer, filter state, rep timestamps, and warmup timer.

SwiftUI Integration

class WorkoutViewModel: ObservableObject {
    @Published var repCount = 0
    @Published var rpm: Double = 0
    @Published var warmupRemaining: TimeInterval = 0

    private let capture = MotionCaptureService()
    private let detector = RepDetectionService()
    private var cancellables = Set<AnyCancellable>()

    init() {
        capture.samplePublisher
            .sink { [weak self] in self?.detector.processBatch($0) }
            .store(in: &cancellables)

        detector.repPublisher
            .receive(on: DispatchQueue.main)
            .sink { [weak self] _ in self?.repCount += 1 }
            .store(in: &cancellables)

        detector.rpmPublisher
            .receive(on: DispatchQueue.main)
            .sink { [weak self] in self?.rpm = $0 }
            .store(in: &cancellables)

        detector.warmupPublisher
            .receive(on: DispatchQueue.main)
            .sink { [weak self] in self?.warmupRemaining = $0 }
            .store(in: &cancellables)
    }

    func startWorkout() {
        detector.reset()
        repCount = 0
        capture.start()
    }

    func stopWorkout() {
        capture.stop()
    }
}