← Writing

Detecting Rowing Strokes from Apple Watch Accelerometer Data

· by Fernando
SwiftCoreMotionSignal ProcessingApple WatchiOS

I own a cheap rowing machine. No Bluetooth, no app connectivity, just an LCD showing time and a number that vaguely resembles calories. I wanted real metrics: stroke count, strokes per minute, power output. The Apple Watch on my wrist has an accelerometer sampling at 50 Hz. Could that signal reliably detect individual rowing strokes?

Yes. But getting there took hundreds of calibration sessions and a detection algorithm that evolved from "count the big spikes" into something with actual signal processing behind it.

What a Rowing Stroke Looks Like in Accelerometer Data

A rowing stroke is a full-body compound movement, but on the wrist it shows up as a repeating peak on one accelerometer axis. The drive phase (the pull) produces a sharp positive acceleration spike on the Y-axis, followed by a gentler return on the recovery.

The problem is everything else in the signal: grip adjustments, machine vibration, wrist rotation, and the fact that stroke intensity changes throughout a session. The peaks at stroke 200 look nothing like stroke 10. A fixed threshold either misses weak strokes or double-counts strong ones.

The Algorithm: Adaptive Peak Detection

The detection pipeline processes each sample through five stages:

1. Axis selection and polarity. Not every rower wears their Watch the same way. The algorithm is configurable per-axis (X, Y, Z) and per-polarity (detect positive or negative peaks). Y-axis positive works for most wrist orientations, but making this tunable was essential.

2. IIR low-pass filter. Raw accelerometer data at 50 Hz is noisy. An exponential smoothing filter cleans up the high-frequency jitter while keeping the stroke peaks intact:

filteredValue = alpha * rawValue + (1 - alpha) * previousFiltered

The alpha parameter (default 0.2) controls the tradeoff. Lower values smooth more but add lag. Fine for 20 SPM rowing, too sluggish for sprint pieces above 30 SPM.

3. Dynamic threshold. Instead of a fixed threshold, the algorithm keeps a rolling window of the last 100 filtered samples and computes their mean and standard deviation. The detection threshold adapts to the signal:

threshold = mean + thresholdMultiplier * standardDeviation

With a default multiplier of 1.5, the threshold tracks the current rowing intensity. Rest periods naturally raise the bar (relative to the quiet signal), and hard efforts keep the threshold proportional to the bigger peaks.

4. Three-point peak detection. A peak is confirmed when the previous sample is higher than both its neighbors (a local maximum). This gets checked against the dynamic threshold and an absolute floor of 0.05g to reject electrical noise during rest.

5. Refractory period. After detecting a stroke, the algorithm ignores new peaks for a configurable window (default 0.4 seconds). Prevents double-counting from secondary oscillations in the same stroke. At 30 SPM (one stroke every 2 seconds), 0.4s is conservative. At sprint rates above 36 SPM, you'd lower it to 0.3s.

The full pipeline runs per-sample with O(1) memory: a few state variables and a 100-sample ring buffer. Lightweight enough to run on the Watch.

The Calibration Problem

The algorithm has seven parameters: alpha, threshold multiplier, refractory period, detection axis, peak polarity, warmup duration, and cooldown duration. Good defaults work for most people, but bodies and equipment vary. Someone on a Concept2 with a high drive has a different wrist acceleration profile than someone on a hydraulic rower with short strokes.

I built a two-pass auto-calibration system. Row a known number of strokes, and the system finds the parameters that match:

Coarse pass: Test 270 combinations across the parameter space (9 threshold values, 5 refractory periods, 3 axes, 2 polarities). Finds the right ballpark.

Fine pass: Take the top 3 candidates and search a tighter grid around each, now also varying alpha. Each combination is scored by detection error, penalizing overshoot (counting too many is worse than too few) and parameter drift from defaults.

This runs offline against recorded motion data, not in real-time. Row, stop, tell it how many strokes you did, and it finds the best fit. The parameters persist in SwiftData and apply to all future workouts.

The Iteration Loop

None of this was designed on a whiteboard. It started as "read CoreMotion, count when Y-axis exceeds 1.0g." Fixed threshold, no filtering. That worked for about 30 seconds until the first false positive.

The process that actually worked was physical: row 200 strokes, look at the data, adjust. I added charts to the debug view early on (raw signal, filtered signal, threshold line, stroke markers overlaid). Being able to see why a stroke was missed or double-counted made all the difference. Without visualization, I'd have been guessing.

Each parameter earned its place by solving a specific failure mode:

  • IIR filter: false detections from grip noise
  • Dynamic threshold: missed strokes when intensity changed mid-session
  • Refractory period: double-counts from post-stroke oscillation
  • Warmup window: false strokes while the filter was still settling
  • Cooldown trimming: phantom strokes when stopping the workout

I also built Bluetooth simulators for Concept2 and FTMS erg protocols, plus an HR monitor simulator. These let me iterate on the workout engine without getting on the rower every time. For the detection algorithm itself though, there's no substitute for real motion data.

Extracting RepMotion

Once the algorithm stabilized, I extracted it into RepMotion, an open-source Swift package for repetitive motion detection. It's not rowing-specific; the same peak detection works for any exercise with a repeating acceleration pattern.

The package splits into three modules:

  • RepMotionCore: data types (MotionSample, RepEvent, RepCalibration)
  • RepMotionDetection: the detection service, auto-calibration, power estimation
  • RepMotionCapture: CoreMotion streaming and test fixture replay

Usage is minimal:

let detector = RepDetectionService()

// Feed accelerometer batches (from Watch or recorded fixture)
detector.processBatch(samples)

// React to detected reps
detector.repPublisher
    .sink { event in
        print("Rep detected — confidence: \(event.confidence)")
    }

Each detected rep includes a confidence score (how far above the baseline the peak was, normalized to 0-1). Turned out to be useful for power estimation too: stronger strokes produce higher-confidence detections, which correlates with drive force.

What I'd Do Differently

Log the calibration sessions. I should have written down what parameters changed, what broke, what improved at each step. By the time the algorithm worked well, I couldn't reconstruct the exact sequence of decisions that got it there. Git history tells the story in broad strokes, but the reasoning behind each parameter change is gone.

I'd also build the simulators earlier. I added them to speed up workout engine development, but they would have saved time during the detection phase too, replaying recorded sessions instead of rowing new ones for every test.

The Takeaway

If you're doing any kind of repeating event detection from sensor data, the pipeline is the same: low-pass filter to clean the signal, dynamic threshold to adapt to changing intensity, peak detection for the events, refractory period to prevent double-counting. The parameters change per domain, the structure doesn't.

The less obvious lesson: visualization is the most important debugging tool. Build charts before you build the algorithm. Seeing the raw signal, filtered signal, and threshold overlaid tells you more in one glance than a hundred log statements.

RowUp is an indoor rowing app for Apple Watch and iPhone, currently entering beta. Read the full project case study for the broader technical story.


← Back to Writing