Source: ios_sensors Β·
ios_sensors.mdΒ· updated 2026-06-22 Β· π secret gistSynced verbatim from gist.github.com/bl9.
iPhone Activity Detection: Walking vs Driving
A Deep-Dive Into Sensor Fusion, CoreMotion, CoreLocation, and the Physics Behind It
Document scope: This reference covers the full stack β from the silicon-level motion coprocessor through iOS frameworks to the application-level fusion classifier β explaining not just what each API does but why it works and when it fails. Code examples are in Swift (iOS 16+). Framework references map to Apple's developer documentation as of iOS 17 / Xcode 15.
Table of Contents
- The Problem: Why GPS Alone Is Not Enough
- The Hardware Layer: Sensors Inside the iPhone
- 2.1 Accelerometer
- 2.2 Gyroscope
- 2.3 Barometer
- 2.4 GPS / GNSS Receiver
- 2.5 The Motion Coprocessor (M-series chip)
- The Framework Layer: Apple's CoreMotion & CoreLocation
- Signal Characteristics: What Each Activity Looks Like
- The Physics and Mathematics Behind Each Signal
- Full Implementation Walkthrough
- 6.1 Project setup and permissions
- 6.2 ActivityType and ActivityResult models
- 6.3 ActivityDetector class structure
- 6.4 Signal 1: CMMotionActivityManager
- 6.5 Signal 2: CMPedometer
- 6.6 Signal 3: Raw accelerometer variance
- 6.7 Signal 4: GPS speed via CLLocationManager
- 6.8 The fusion classifier β vote-based decision logic
- 6.9 Wiring it into a ViewController
- Scenario Walkthroughs
- 7.1 Scenario A: Normal sidewalk walk
- 7.2 Scenario B: Driving on a motorway
- 7.3 Scenario C: Sitting in a slow-moving traffic jam
- 7.4 Scenario D: Passenger in a car (not driving)
- 7.5 Scenario E: Running for a bus
- 7.6 Scenario F: GPS-denied environment (underground car park)
- 7.7 Scenario G: Cycling
- 7.8 Scenario H: Treadmill walking
- Edge Cases and Known Failure Modes
- Real-World Use Cases and Applications
- Battery and Performance Considerations
- Improving Accuracy: Advanced Techniques
- Privacy, Permissions, and App Store Compliance
- Testing Strategy
- Broader Perspectives: How Other Platforms Approach This
- References and Further Reading
1. The Problem: Why GPS Alone Is Not Enough
At first glance, distinguishing walking from driving seems trivial: check the speed. A person walks at roughly 1.4 m/s; a car moves at 14 m/s or more. Just threshold on GPS velocity β done.
In practice this fails in at least five common situations:
Traffic jams. A car crawling at 0.5 m/s through gridlock produces a GPS reading indistinguishable from brisk walking. GPS alone would classify the driver as a pedestrian.
GPS lag and multipath. In urban canyons β surrounded by tall buildings β GPS signals bounce off surfaces before reaching the receiver. This causes "multipath error" where the reported position jumps, making computed speed unreliable or wildly incorrect. A pedestrian walking steadily might briefly read 12 m/s due to a bad fix.
GPS unavailability. Tunnels, underground car parks, dense indoor environments, and some rural areas with weak satellite coverage all produce invalid speed readings (CLLocation.speed == -1). The app must degrade gracefully and rely on other signals.
Speed overlap zones. A slow cyclist (3β5 m/s) overlaps with a runner (2.5β5 m/s). An electric scooter overlaps with a fast cyclist. Speed alone cannot separate these.
Cold starts. A GPS receiver that has been off for minutes needs 30β90 seconds to acquire satellites and produce an accurate fix. During that window, the initial speed readings can be zero or garbage.
The solution is sensor fusion: combining multiple independent signal sources so that each compensates for the others' weaknesses. GPS is unreliable in tunnels; the accelerometer still works underground. The coprocessor is unreliable at 0.5 m/s; GPS is accurate above 6 m/s. Together they cover the failure modes that no single sensor handles alone.
2. The Hardware Layer: Sensors Inside the iPhone
2.1 Accelerometer
The accelerometer is a MEMS (Micro-Electro-Mechanical Systems) device β a microscopic silicon proof mass suspended by springs inside the chip. When the device accelerates, the proof mass deflects relative to the housing. That deflection is measured capacitively (by how the gap between two conducting plates changes) and converted to a voltage proportional to acceleration in each axis.
Modern iPhones use a three-axis accelerometer, producing independent readings along X (leftβright), Y (upβdown), and Z (frontβback) when the phone is held upright. The raw output is in units of g (standard gravity, 9.81 m/sΒ²).
Important note on gravity: Even when the phone is completely stationary, the accelerometer reads approximately 1 g on whichever axis points toward the ground, because it is measuring the electromagnetic contact force that counteracts gravity, not true inertial acceleration. This is why computing the magnitude of the acceleration vector and subtracting 1 g (or comparing variance rather than absolute values) is important for motion analysis.
Sampling rates available through CoreMotion:
| Rate | Typical use |
|---|---|
| 10 Hz | Background step detection, coarse activity |
| 50 Hz | Real-time activity classification (our use case) |
| 100 Hz | Gesture recognition, sports tracking |
| 200β400 Hz (device-dependent) | Vibration analysis, fall detection |
The accelerometer consumes roughly 0.5β1 mA at 50 Hz β modest compared to GPS (~20β30 mA) but non-trivial for background apps.
2.2 Gyroscope
The gyroscope measures angular velocity (rotational rate) in degrees/second or radians/second around each axis. It uses a vibrating MEMS structure; Coriolis forces cause the vibration axis to shift during rotation, which is measured capacitively.
In activity detection, the gyroscope is less directly useful than the accelerometer, but it contributes to:
- Device orientation tracking β knowing whether the phone is in a pocket, on a table, or in a hand changes how you interpret accelerometer data.
- Reducing false positives from hand gestures β shaking the phone while sitting still produces accelerometer variance that could be mistaken for walking. The gyroscope signature of a hand shake (rapid, uncorrelated rotation) differs from a walking signature (periodic linear oscillation with minimal rotation).
- CMDeviceMotion β the
CMDeviceMotionAPI fuses accelerometer + gyroscope + magnetometer using a complementary filter to produce stable attitude (pitch, roll, yaw) estimates. This is not used in our basic implementation but is available.
2.3 Barometer
The barometer measures atmospheric pressure, which decreases at approximately 12 Pa per metre of altitude gain. This makes it useful for floor-level detection in multi-story buildings and staircase recognition.
In walking vs driving detection, the barometer is a secondary signal: a person climbing stairs shows accelerometer patterns similar to walking but with steadily decreasing pressure; a car going up a parking structure ramp shows driving-speed GPS with a slow pressure drop.
2.4 GPS / GNSS Receiver
Modern iPhones support multiple GNSS (Global Navigation Satellite System) constellations simultaneously:
- GPS β US Department of Defense, ~31 satellites
- GLONASS β Russian system, ~24 satellites
- Galileo β European Union, ~28 satellites
- BeiDou β Chinese system, ~35 satellites
- QZSS β Japanese regional system (useful in East Asia)
Supporting multiple constellations means more visible satellites at any given location, which dramatically improves fix accuracy and reduces time-to-first-fix. In open sky, modern iPhones achieve horizontal accuracy of 3β5 metres.
The iOS CLLocation object exposes:
location.speed // m/s, or -1 if invalid
location.speedAccuracy // m/s 1-sigma error; negative means invalid
location.horizontalAccuracy // metres, ~68% confidence radius
location.coordinate // CLLocationCoordinate2D
location.altitude // metres above sea level (WGS-84)
location.timestamp // Date of the reading
The speedAccuracy field is critical and often ignored. A speed reading of 1.5 m/s with speedAccuracy of 0.3 m/s is quite reliable; the same speed reading with speedAccuracy of 4.0 m/s is useless noise. The ActivityDetector implementation checks speedAccuracy >= 0 before trusting the speed.
2.5 The Motion Coprocessor (M-series chip)
Starting with the iPhone 5s (A7 chip with M7 coprocessor), Apple added a dedicated low-power motion coprocessor that runs independently of the main CPU. Even when the iPhone is in deep sleep β screen off, app in background β the M-series chip continues collecting and classifying motion data.
The coprocessor:
- Samples the accelerometer at low rates continuously
- Runs a built-in activity classifier (believed to use a Hidden Markov Model β see Section 5.4)
- Stores up to 7 days of historical activity data in hardware, accessible retrospectively via
CMMotionActivityManager.queryActivityStarting(from:to:to:withHandler:) - Consumes approximately 0.1 mA β orders of magnitude less than keeping the main CPU awake
This is why CMMotionActivityManager is the recommended first-line API for activity detection. You get high-quality classification essentially for free, power-wise.
3. The Framework Layer: Apple's CoreMotion & CoreLocation
3.1 CMMotionActivityManager β the coprocessor shortcut
CMMotionActivityManager is the highest-level API available. It surfaces the coprocessor's built-in activity classifier as a stream of CMMotionActivity objects.
Key properties of CMMotionActivity:
activity.walking // Bool β user is likely walking
activity.running // Bool β user is likely running
activity.cycling // Bool β user is likely cycling
activity.automotive // Bool β user is likely in a vehicle
activity.stationary // Bool β user is likely not moving
activity.confidence // CMMotionActivityConfidence: .low | .medium | .high
activity.startDate // Date when this activity started
Note that multiple booleans can be true simultaneously. This happens during transitions β e.g., stepping out of a car, automotive and walking are both true briefly. The confidence property reflects how certain the coprocessor is.
Checking availability:
guard CMMotionActivityManager.isActivityAvailable() else {
// Older devices (pre-iPhone 5s) or non-supported hardware
return
}
Starting real-time updates:
let manager = CMMotionActivityManager()
manager.startActivityUpdates(to: .main) { activity in
guard let activity = activity else { return }
// Priority order matters β automotive takes precedence
if activity.automotive {
print("Driving β confidence: \(activity.confidence)")
} else if activity.running {
print("Running")
} else if activity.walking {
print("Walking")
} else if activity.stationary {
print("Stationary")
}
}
Querying historical data:
let yesterday = Date().addingTimeInterval(-86400)
let now = Date()
manager.queryActivityStarting(from: yesterday, to: now, to: .main) { activities, error in
guard let activities = activities, error == nil else { return }
let drivingTime = activities
.filter { $0.automotive && $0.confidence != .low }
.map { $0.startDate }
// compute durations between consecutive events...
print("Activities in past 24h: \(activities.count)")
}
Required plist key:
<key>NSMotionUsageDescription</key>
<string>Used to detect whether you are walking or driving to adapt app behaviour.</string>
3.2 CMPedometer β step detection and cadence
CMPedometer provides higher-level walking metrics derived from the accelerometer via the motion coprocessor:
numberOfStepsβ cumulative steps since the start datecurrentPaceβ current pace in seconds per metre (β not per step)currentCadenceβ steps per second (iOS 9+)distanceβ estimated walking distance in metresfloorsAscended/floorsDescendedβ floors climbed/descended (requires barometer)
Current cadence vs. current pace: A subtle but important distinction. currentCadence is directly steps/second. currentPace is seconds/metre β it represents how long it takes to cover a metre of ground, combining both step frequency and stride length. For activity classification, cadence is more directly useful because it doesn't depend on stride length estimation.
let pedometer = CMPedometer()
guard CMPedometer.isStepCountingAvailable() else { return }
pedometer.startUpdates(from: Date()) { data, error in
guard let data = data, error == nil else { return }
// currentCadence is steps/second directly
if let cadence = data.currentCadence {
let stepsPerSecond = cadence.doubleValue
print("Cadence: \(stepsPerSecond) steps/sec")
}
// currentPace is sec/metre β invert for steps/sec if no cadence available
if let pace = data.currentPace {
let secondsPerStep = pace.doubleValue
let stepsPerSecond = 1.0 / secondsPerStep
print("Step freq (from pace): \(stepsPerSecond) steps/sec")
}
}
Why cadence is a powerful discriminator:
Human gait has a remarkably consistent cadence range. Across all ages and heights:
- Strolling: 0.8β1.2 steps/sec
- Normal walking: 1.4β1.8 steps/sec
- Brisk walking: 1.8β2.5 steps/sec
- Running: 2.5β4.0 steps/sec
- Sprint: 4.0β5.0 steps/sec
A person sitting in a car, even on a bumpy road, registers essentially 0 steps/sec. This makes near-zero cadence a strong vote for driving or stationary.
Checking device support before using:
CMPedometer.isStepCountingAvailable() // accelerometer-based step count
CMPedometer.isCadenceAvailable() // iOS 9+, requires M7+
CMPedometer.isDistanceAvailable() // M7+ with motion coprocessor
CMPedometer.isFloorCountingAvailable() // requires barometer
3.3 CMMotionManager β raw accelerometer data
CMMotionManager provides direct access to raw accelerometer (and gyroscope, magnetometer, and device motion) streams. Unlike CMMotionActivityManager, this runs on the main application processor β it stops when the app is backgrounded unless Background Modes are enabled.
Setting up 50 Hz accelerometer updates:
let motionManager = CMMotionManager()
guard motionManager.isAccelerometerAvailable else { return }
// 50 Hz = 0.02 second interval
motionManager.accelerometerUpdateInterval = 1.0 / 50.0
motionManager.startAccelerometerUpdates(to: .main) { data, error in
guard let data = data, error == nil else { return }
// data.acceleration is in g (1 g β 9.81 m/sΒ²)
let x = data.acceleration.x
let y = data.acceleration.y
let z = data.acceleration.z
// Magnitude of the acceleration vector
let magnitude = sqrt(x*x + y*y + z*z)
// At rest: magnitude β 1.0 (gravity vector)
// Walking: magnitude oscillates ~0.8β1.2 with each step
// Driving on smooth road: magnitude β 1.0 with small perturbations
// Pothole or speed bump: magnitude spikes to 1.5β2.5 briefly
}
CMAcceleration axes (device held upright, portrait):
+Y (up)
β
β
βββββββΌββββββ +X (right)
β
β
(screen faces +Z)
Since phone orientation varies (pocket, bag, car mount), axis-specific analysis is fragile. The magnitude sqrt(xΒ² + yΒ² + zΒ²) is orientation-invariant and is what we use for variance computation.
Stopping updates properly:
motionManager.stopAccelerometerUpdates()
// Also available:
motionManager.stopGyroUpdates()
motionManager.stopDeviceMotionUpdates()
3.4 CLLocationManager β GPS speed and heading
CLLocationManager is the gateway to all location and heading data. For activity detection, the key fields are speed and speedAccuracy.
Accuracy modes and their trade-offs:
// Best accuracy β full GPS, heavy battery use
locationManager.desiredAccuracy = kCLLocationAccuracyBest
// ~10m accuracy, moderate battery
locationManager.desiredAccuracy = kCLLocationAccuracyNearestTenMeters
// ~100m accuracy β uses cell towers + WiFi, very battery-efficient
locationManager.desiredAccuracy = kCLLocationAccuracyHundredMeters
For activity detection, kCLLocationAccuracyBest is recommended because we need the Doppler-derived velocity measurement, which requires a full GNSS fix. Cell tower triangulation does not produce a reliable speed value.
Filtering out bad speed readings:
func locationManager(_ manager: CLLocationManager,
didUpdateLocations locations: [CLLocation]) {
guard let location = locations.last else { return }
// Negative speedAccuracy means the speed reading is invalid
guard location.speedAccuracy >= 0 else { return }
// Optional: filter out extremely noisy readings
guard location.speedAccuracy < 2.0 else { return } // m/s error too high
// speed can be -1 if no valid speed measurement; clamp to 0
let speed = max(0, location.speed)
print("Speed: \(speed) m/s Β±\(location.speedAccuracy) m/s")
}
The distanceFilter property controls how many metres the device must move before a location update fires. Setting distanceFilter = 2 (metres) for walking detection is a good balance β it prevents excessive callbacks when stationary while still catching slow pedestrian movement.
4. Signal Characteristics: What Each Activity Looks Like
4.1 GPS Speed Profile
| Activity | Typical speed range | Notes |
|---|---|---|
| Stationary | 0β0.3 m/s | GPS noise floor; readings oscillate even when standing still |
| Slow walk | 0.5β1.2 m/s | Older adults, browsing on a phone |
| Normal walk | 1.2β1.8 m/s | Average adult pace (~4.5 km/h) |
| Brisk walk | 1.8β2.2 m/s | Commuter pace |
| Running (slow jog) | 2.2β4.0 m/s | 8β14 min/km |
| Running (fast) | 4.0β7.0 m/s | Sub-8 min/km |
| Cycling (slow) | 3.0β5.0 m/s | Urban cycling |
| Cycling (fast) | 5.0β12.0 m/s | Road cycling |
| Traffic jam driving | 0β2.0 m/s | Overlap with walking range |
| Urban driving | 6.0β14.0 m/s | 22β50 km/h |
| Motorway driving | 22β40+ m/s | 80β145+ km/h |
The overlap between walking, slow cycling, and traffic-jam driving at 0β2 m/s is the core challenge GPS speed alone cannot resolve.
4.2 Step Frequency Profile
| Activity | Steps/second | Notes |
|---|---|---|
| Stationary | 0 | Occasional fidget may produce 0.1β0.3 briefly |
| Strolling | 0.8β1.2 | Window shopping, corridors |
| Normal walk | 1.4β1.8 | Most adults |
| Brisk walk | 1.8β2.5 | Exercise walking |
| Running | 2.5β4.0 | Increases with speed |
| Sprint | 4.0β5.0 | Elite sprinters up to 5.5 |
| Cycling | 0 | No footsteps (pedalling doesn't register) |
| Driving | 0 | No footsteps |
Step frequency is the cleanest discriminator between walking/running and everything else. Zero cadence rules out walking or running entirely.
4.3 Accelerometer Variance Profile
Variance measures how much the acceleration magnitude fluctuates around its mean over a 1-second window.
| Activity | Typical variance | Explanation |
|---|---|---|
| Stationary on table | <0.001 | Essentially no motion |
| Held stationary | 0.002β0.010 | Micro hand tremors |
| Driving (smooth motorway) | 0.005β0.020 | Engine vibration, smooth road |
| Driving (city with potholes) | 0.020β0.100 | Road surface irregularities |
| Walking (in pocket) | 0.020β0.080 | Hip-transmitted step bounce |
| Walking (in hand) | 0.050β0.200 | Arm swing adds to hip movement |
| Running | 0.100β0.400 | High-impact footstrikes |
| Cycling | 0.010β0.050 | Smooth cadence, less up-down |
Key insight: Driving on a smooth motorway has similar variance to a phone held in a steady hand, which is lower than walking variance. However, driving over potholes can spike variance to values indistinguishable from walking. This is why variance alone is an unreliable discriminator β it's best used as supporting evidence when GPS or pedometer data is ambiguous.
4.4 Summary Signal Table
A quick reference for the fusion logic:
| Signal | Walking | Driving | Running | Cycling | Stationary |
|---|---|---|---|---|---|
| GPS speed (m/s) | 0.5β2.2 | >6 (usually >10) | 2.2β7 | 3β12 | 0β0.3 |
| Cadence (steps/s) | 1.2β2.5 | ~0 | 2.5β5 | ~0 | ~0 |
| Accel variance | 0.02β0.2 | 0.005β0.05 | 0.1β0.4 | 0.01β0.05 | <0.01 |
| CMMotionActivity | .walking | .automotive | .running | .cycling | .stationary |
5. The Physics and Mathematics Behind Each Signal
5.1 How GNSS computes speed (Doppler vs position-differencing)
There are two methods a GNSS receiver can use to compute speed:
Position-differencing (less accurate):
speed β Ξposition / Ξtime
You compute two positions 1 second apart and divide the distance by the elapsed time. This is simple but inherits all the noise of position estimation β if either position has a 5-metre error, the computed speed can be wildly wrong at pedestrian speeds.
Doppler measurement (far more accurate):
When a satellite and the receiver are in relative motion, the frequency of the satellite's carrier signal shifts by the Doppler effect:
Ξf = (v_rel / c) Γ f_carrier
Where v_rel is the relative radial velocity, c is the speed of light (~3Γ10βΈ m/s), and f_carrier is the GPS L1 carrier frequency (1575.42 MHz).
Modern GNSS receivers, including those in iPhones, measure this Doppler shift directly from the carrier phase tracking loop. Because the carrier frequency is ~1.5 GHz, even a 0.1 Hz Doppler shift corresponds to ~0.02 m/s velocity β far more precise than position-differencing.
Apple's CLLocation returns Doppler-derived speed when available. This is why speed from a good GPS fix at pedestrian velocities (1β2 m/s) can be accurate to Β±0.3 m/s β useful for distinguishing 1.4 m/s walking from 0.5 m/s shuffle.
5.2 Step detection: peak-finding on the acceleration magnitude vector
The coprocessor's step detection algorithm (and CMPedometer) works by finding peaks in the acceleration magnitude signal. Here's a simplified version of the logic:
magnitude(t) = sqrt(ax(t)Β² + ay(t)Β² + az(t)Β²)
During walking, each footstrike causes the body's centre of mass to decelerate slightly, then accelerate again on toe-off. This produces a signature waveform in the magnitude signal:
Magnitude (g)
1.4 | * *
1.2 | * * * *
1.0 | * * * *
0.8 | * *
|______________________ time β
β β β β
footstrikes (steps)
The frequency of these peaks is the cadence. Peak detection requires:
- Low-pass filtering to remove high-frequency noise (the coprocessor does this in hardware)
- Finding local maxima above a threshold (~1.2 g for the magnitude)
- Requiring a minimum time between peaks (~0.25 sec = max ~4 steps/sec) to avoid double-counting
The coprocessor also learns your personal gait signature through calibration (done automatically over time or manually via the Health app), which is why step count accuracy improves after the first few days of use.
5.3 Population variance as a motion roughness measure
The variance of a set of N values is:
ΟΒ² = (1/N) Γ Ξ£(xα΅’ - ΞΌ)Β²
where ΞΌ = (1/N) Γ Ξ£xα΅’ (the mean)
In Swift:
func variance(of values: [Double]) -> Double {
guard values.count > 1 else { return 0 }
let mean = values.reduce(0, +) / Double(values.count)
let sumSquaredDeviations = values.reduce(0) { $0 + ($1 - mean) * ($1 - mean) }
return sumSquaredDeviations / Double(values.count)
}
Applied to a rolling 50-sample (1-second at 50 Hz) window of acceleration magnitudes, this gives a scalar measure of how "rough" the motion is.
Why a rolling window rather than all-time variance?
A cumulative variance from the moment the app started would be dominated by historical data and would react very slowly to changes. A 1-second rolling window captures the current motion state. The choice of window length involves a trade-off:
- Shorter window (0.5 sec / 25 samples): Responds faster to mode changes, but more susceptible to momentary spikes (a single pothole).
- Longer window (2 sec / 100 samples): More stable classification, but slower to detect transitions from walking to driving.
A 1-second window at 50 Hz is a reasonable default for most applications.
Why magnitude rather than a single axis?
Using the magnitude sqrt(xΒ² + yΒ² + zΒ²) makes the computation rotation-invariant. You don't need to know which way the phone is oriented. A step impulse appears as a magnitude spike regardless of whether the phone is in a jacket pocket, a trouser pocket, or held in the hand.
5.4 Why the M-series coprocessor uses a Hidden Markov Model
The coprocessor's activity classifier almost certainly uses a Hidden Markov Model (HMM), based on Apple patents and academic literature. Here's the intuition:
An HMM models a system that transitions between hidden states (the activities: walking, driving, etc.) over time. The key insight is that activities have temporal continuity β you're unlikely to switch from driving to walking in 0.1 seconds. The HMM encodes this via transition probabilities:
P(driving β walking in 1 sec) β 0.001 (very unlikely)
P(driving β driving in 1 sec) β 0.999 (very likely)
P(walking β running in 1 sec) β 0.05 (possible)
At each time step, the model observes the sensor data (the "emission") and updates its belief about the current hidden state using Bayes' rule:
P(state | observations) β P(observations | state) Γ P(state | previous_state)
This is what makes the coprocessor robust to brief ambiguous readings. A single bumpy-road sample that looks like walking variance won't flip the classifier to "walking" if the transition probability from automotive to walking is very low and GPS is reading 80 km/h.
The HMM's parameters (transition probabilities and observation distributions) are trained offline on large datasets of labelled activities collected from real users. Apple's implementation also appears to do online adaptation β the classifier personalises to your gait over time, which is why the system becomes more accurate after a few days.
6. Full Implementation Walkthrough
6.1 Project setup and permissions
Add entitlements and plist keys:
In Info.plist:
<!-- Required for CLLocationManager -->
<key>NSLocationWhenInUseUsageDescription</key>
<string>We use your location to detect whether you are walking or driving.</string>
<!-- Required only if you need location in the background -->
<key>NSLocationAlwaysAndWhenInUseUsageDescription</key>
<string>Background location allows activity detection to continue when the app is minimised.</string>
<!-- Required for CMMotionActivityManager -->
<key>NSMotionUsageDescription</key>
<string>Motion data lets us distinguish walking from driving without draining your battery.</string>
Background Modes (if you need detection while app is in background):
In Signing & Capabilities β Background Modes:
- β Location updates (for GPS-based detection)
- β Motion and Fitness (automatically enabled by CoreMotion)
Note: CMMotionActivityManager and CMPedometer continue running through the coprocessor regardless of background modes. Only CMMotionManager (raw accelerometer) stops in the background unless you have a specific background entitlement.
Minimum deployment target: iOS 11+ is required for all APIs used. currentCadence on CMPedometer requires iOS 9+. The M-series motion coprocessor is available from iPhone 5s (2013) onward.
6.2 ActivityType and ActivityResult models
import Foundation
/// The classified activity type
enum ActivityType: String, CaseIterable {
case walking = "πΆ Walking"
case driving = "π Driving"
case running = "π Running"
case cycling = "π΄ Cycling"
case stationary = "π§ Stationary"
case unknown = "β Unknown"
/// Human-readable description without emoji
var plainName: String {
switch self {
case .walking: return "Walking"
case .driving: return "Driving"
case .running: return "Running"
case .cycling: return "Cycling"
case .stationary: return "Stationary"
case .unknown: return "Unknown"
}
}
/// Whether this activity type involves self-propulsion on foot
var isPedestrian: Bool {
return self == .walking || self == .running
}
/// Whether this activity suggests the user is inside a vehicle
var isVehicular: Bool {
return self == .driving
}
}
/// A single classified activity observation with all supporting signal values
struct ActivityResult {
let type: ActivityType
let confidence: String // "low", "medium", or "high"
let speed: Double // m/s from GPS (-1 if unavailable)
let stepFrequency: Double // steps/sec from pedometer (0 if not walking)
let accelerationVariance: Double // dimensionless variance of magnitude buffer
let timestamp: Date
/// Speed converted to km/h for display
var speedKMH: Double {
return speed >= 0 ? speed * 3.6 : -1
}
/// Confidence expressed as a 0β1 numeric weight (for downstream weighting)
var confidenceWeight: Double {
switch confidence {
case "high": return 1.0
case "medium": return 0.6
default: return 0.3
}
}
/// Debug description
var debugDescription: String {
let speedStr = speed >= 0 ? String(format: "%.1f km/h", speedKMH) : "unavailable"
return """
βββββββββββββββββββββββββββββββββββββ
Activity : \(type.rawValue)
Confidence : \(confidence)
GPS speed : \(speedStr)
Step freq : \(String(format: "%.2f", stepFrequency)) steps/s
Accel var : \(String(format: "%.4f", accelerationVariance))
Time : \(timestamp)
βββββββββββββββββββββββββββββββββββββ
"""
}
}
6.3 ActivityDetector class structure
import CoreMotion
import CoreLocation
import Foundation
class ActivityDetector: NSObject {
// ββ CoreMotion ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
/// High-level coprocessor activity classifier
private let motionActivityManager = CMMotionActivityManager()
/// Step counting and cadence
private let pedometer = CMPedometer()
/// Raw accelerometer stream
private let motionManager = CMMotionManager()
// ββ CoreLocation ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
/// GPS speed and position
private let locationManager = CLLocationManager()
// ββ Rolling signal state ββββββββββββββββββββββββββββββββββββββββββββββββββ
/// Last valid GPS speed in m/s; -1 if no valid reading yet
private var currentGPSSpeed: Double = -1
/// Current step frequency in steps/sec from pedometer
private var currentStepFrequency: Double = 0
/// Rolling buffer of acceleration magnitude samples (1 second at 50 Hz)
private var recentAccelSamples: [Double] = []
private let sampleBufferMax = 50
/// Derived variance of the rolling buffer
private var currentAccelVariance: Double = 0
// ββ Output ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
/// Called every time a new fused classification is produced.
/// Invoked on the main thread.
var onActivityDetected: ((ActivityResult) -> Void)?
// ββ Lifecycle βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
func startDetecting() {
requestPermissions()
startHighLevelActivityMonitoring()
startPedometerUpdates()
startRawAccelerometer()
startGPS()
}
func stopDetecting() {
motionActivityManager.stopActivityUpdates()
pedometer.stopUpdates()
motionManager.stopAccelerometerUpdates()
locationManager.stopUpdatingLocation()
}
}
6.4 Signal 1: CMMotionActivityManager
private func startHighLevelActivityMonitoring() {
guard CMMotionActivityManager.isActivityAvailable() else {
// Pre-M7 devices (iPhone 5 and earlier) don't have the coprocessor.
// In practice this is only relevant if you support iOS 11 on very old hardware.
return
}
motionActivityManager.startActivityUpdates(to: .main) { [weak self] activity in
guard let self = self, let activity = activity else { return }
// This callback fires when the coprocessor detects an activity change.
// It is NOT fired at a fixed rate β only on transitions.
let classified = self.classifyHighLevel(activity)
// Log to console for debugging
print("[CMMotionActivity] \(classified.rawValue) confidence: \(activity.confidence)")
// Note: we don't call evaluateAndReport() here because
// CMMotionActivityManager alone is our sanity check, not the
// primary fused decision-maker. You could add it here if desired.
}
}
/// Translates CMMotionActivity boolean flags to our ActivityType enum.
/// Priority order: automotive > running > walking > cycling > stationary
private func classifyHighLevel(_ a: CMMotionActivity) -> ActivityType {
// automotive flag is most distinct β don't deprioritise it
if a.automotive { return .driving }
if a.running { return .running }
if a.walking { return .walking }
if a.cycling { return .cycling }
if a.stationary { return .stationary }
return .unknown
}
Why prioritise automotive above walking?
In transition moments (getting into a car, stepping onto a moving train platform), multiple flags can be true. automotive is the correct dominant classification in those cases because it means the user is inside a vehicle, regardless of incidental walking motion detected.
6.5 Signal 2: CMPedometer
private var currentStepFrequency: Double = 0
private func startPedometerUpdates() {
guard CMPedometer.isStepCountingAvailable() else {
print("CMPedometer: step counting unavailable on this device")
return
}
// startUpdates fires as steps accumulate; the interval is not fixed.
// Typical update rate: every 1β2 steps.
pedometer.startUpdates(from: Date()) { [weak self] data, error in
guard let self = self, let data = data, error == nil else { return }
// Prefer currentCadence (direct steps/sec) over deriving from currentPace
if let cadence = data.currentCadence {
self.currentStepFrequency = cadence.doubleValue
} else if let pace = data.currentPace {
// pace is seconds/metre; to get steps/sec:
// steps/sec = stride_length_m / seconds_per_step
// Without stride length we approximate:
// seconds_per_step β pace (this is actually sec/m but works as proxy)
self.currentStepFrequency = 1.0 / pace.doubleValue
}
// Trigger a new fused evaluation
DispatchQueue.main.async {
self.evaluateAndReport()
}
}
}
Why does currentPace approximate seconds_per_step reasonably well?
The pedometer's stride length model estimates stride length from cadence and historical calibration. For an average adult at normal walking speed, stride length is approximately 0.75 m. currentPace (sec/metre) Γ· 0.75 gives seconds/step. When used just for the threshold check (is cadence > 1.2?), this approximation is sufficient.
6.6 Signal 3: Raw accelerometer variance
private var currentAccelVariance: Double = 0
private func startRawAccelerometer() {
guard motionManager.isAccelerometerAvailable else { return }
// 50 Hz is a good balance: captures the ~1.5β2 Hz walking frequency
// well above Nyquist (which requires at least 4 Hz sample rate for a 2 Hz signal),
// while not overwhelming the main queue with updates.
motionManager.accelerometerUpdateInterval = 1.0 / 50.0
motionManager.startAccelerometerUpdates(to: .main) { [weak self] data, error in
guard let self = self, let data = data, error == nil else { return }
// Compute orientation-invariant acceleration magnitude
let x = data.acceleration.x
let y = data.acceleration.y
let z = data.acceleration.z
let magnitude = sqrt(x*x + y*y + z*z)
// Maintain a rolling 1-second buffer
self.recentAccelSamples.append(magnitude)
if self.recentAccelSamples.count > self.sampleBufferMax {
self.recentAccelSamples.removeFirst() // O(n) β use a ring buffer for production
}
// Recompute variance over the buffer
self.currentAccelVariance = self.variance(of: self.recentAccelSamples)
// Note: we don't call evaluateAndReport() on every accelerometer sample
// (50 times/sec is too frequent). Pedometer and GPS callbacks drive reporting.
}
}
/// Population variance: ΟΒ² = (1/N) Ξ£(xα΅’ - ΞΌ)Β²
private func variance(of values: [Double]) -> Double {
guard values.count > 1 else { return 0.0 }
let count = Double(values.count)
let mean = values.reduce(0.0, +) / count
let sumSquaredDeviations = values.reduce(0.0) { accumulator, value in
let deviation = value - mean
return accumulator + deviation * deviation
}
return sumSquaredDeviations / count
}
Production optimisation β ring buffer:
The removeFirst() call on an array is O(n) because it shifts all remaining elements. For a 50-element buffer at 50 Hz this is negligible, but for higher sample rates, use ArraySlice or a proper ring buffer:
// Simple ring buffer alternative
private var accelRingBuffer = [Double](repeating: 0, count: 50)
private var accelWriteIndex = 0
// In the update handler:
accelRingBuffer[accelWriteIndex % sampleBufferMax] = magnitude
accelWriteIndex += 1
// Variance computation still uses accelRingBuffer[0..<sampleBufferMax]
6.7 Signal 4: GPS speed via CLLocationManager
private func requestPermissions() {
locationManager.delegate = self
locationManager.requestWhenInUseAuthorization()
}
private func startGPS() {
locationManager.desiredAccuracy = kCLLocationAccuracyBest
// Minimum distance filter: update every 2 metres.
// This prevents callbacks when standing still due to GPS noise oscillation.
locationManager.distanceFilter = 2.0
locationManager.startUpdatingLocation()
}
// CLLocationManagerDelegate
extension ActivityDetector: CLLocationManagerDelegate {
func locationManagerDidChangeAuthorization(_ manager: CLLocationManager) {
switch manager.authorizationStatus {
case .authorizedWhenInUse, .authorizedAlways:
manager.startUpdatingLocation()
case .denied, .restricted:
// Degrade gracefully: rely only on motion sensors
print("Location access denied β GPS speed unavailable")
default:
break
}
}
func locationManager(_ manager: CLLocationManager,
didUpdateLocations locations: [CLLocation]) {
// Use the most recent location
guard let location = locations.last else { return }
// Reject readings where the speed measurement is invalid.
// speedAccuracy < 0 indicates the speed is not available for this fix.
guard location.speedAccuracy >= 0 else { return }
// CLLocation.speed can technically be -1 for no measurement;
// clamp to 0 to avoid negative speeds entering our logic.
currentGPSSpeed = max(0.0, location.speed)
// A new GPS reading triggers a fused evaluation
evaluateAndReport()
}
func locationManager(_ manager: CLLocationManager, didFailWithError error: Error) {
guard let clError = error as? CLError else { return }
switch clError.code {
case .denied:
print("GPS denied by user")
case .locationUnknown:
// Transient β GPS is trying to acquire a fix
currentGPSSpeed = -1
default:
print("GPS error: \(clError.localizedDescription)")
}
}
}
6.8 The fusion classifier β vote-based decision logic
private func evaluateAndReport() {
let speed = currentGPSSpeed // m/s (-1 = unavailable)
let stepFreq = currentStepFrequency // steps/sec
let accelVar = currentAccelVariance // dimensionless
let (type, confidence) = fuseSignals(
speed: speed,
stepFrequency: stepFreq,
accelVariance: accelVar
)
let result = ActivityResult(
type: type,
confidence: confidence,
speed: speed,
stepFrequency: stepFreq,
accelerationVariance: accelVar,
timestamp: Date()
)
onActivityDetected?(result)
}
private func fuseSignals(
speed: Double,
stepFrequency: Double,
accelVariance: Double
) -> (ActivityType, String) {
// ββ Thresholds (tuneable) βββββββββββββββββββββββββββββββββββββββββββββ
let drivingSpeedThreshold: Double = 6.0 // m/s β 21.6 km/h β 13.4 mph
let walkingSpeedMax: Double = 2.2 // m/s β 7.9 km/h β 4.9 mph
let runningSpeedMin: Double = 2.2 // m/s
let walkingStepMin: Double = 1.2 // steps/sec (slow walk)
let runningStepMin: Double = 2.5 // steps/sec (jog boundary)
let noStepThreshold: Double = 0.2 // steps/sec (essentially zero)
let drivingAccelVarMax: Double = 0.02 // very smooth ride
let walkingAccelVarMin: Double = 0.02 // rhythmic step bounce
// βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
var votes: [ActivityType: Int] = [:]
// ββ Vote 1: GPS speed (weight 3 for driving, 2 for others) ββββββββββββ
// GPS speed is the most definitive signal when reliable.
if speed >= 0 {
if speed > drivingSpeedThreshold {
votes[.driving, default: 0] += 3 // very strong: no pedestrian moves this fast
} else if speed < walkingSpeedMax {
votes[.walking, default: 0] += 2 // probably walking or slow
} else {
// Speed is in the 2.2β6.0 range: could be fast walk, run, or slow cycle
votes[.running, default: 0] += 2
}
}
// If speed < 0 (unavailable), this entire block is skipped β
// the other two signals carry the classification alone.
// ββ Vote 2: Step frequency (weight 2) βββββββββββββββββββββββββββββββββ
// Cadence is a clean binary: you're either stepping or you're not.
if stepFrequency > runningStepMin {
votes[.running, default: 0] += 2
} else if stepFrequency > walkingStepMin {
votes[.walking, default: 0] += 2
} else if stepFrequency < noStepThreshold {
// Near-zero cadence: driving or stationary
votes[.driving, default: 0] += 1
}
// ββ Vote 3: Accelerometer variance (weight 1) ββββββββββββββββββββββββββ
// Secondary evidence β corroborates but doesn't decide.
if accelVariance < drivingAccelVarMax {
votes[.driving, default: 0] += 1 // smooth: consistent with a vehicle
} else if accelVariance > walkingAccelVarMin {
votes[.walking, default: 0] += 1 // bouncy: consistent with walking
}
// ββ Tally ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
let winner = votes.max { $0.value < $1.value }
let type = winner?.key ?? .unknown
let score = winner?.value ?? 0
// Map total vote score to confidence
let confidence: String
switch score {
case 5...: confidence = "high" // β₯2 signals strongly agree
case 3...4: confidence = "medium" // majority signal agrees
default: confidence = "low" // weak or conflicting signals
}
return (type, confidence)
}
How the vote scores map to scenarios:
| Scenario | GPS votes | Cadence votes | Var votes | Total | Confidence |
|---|---|---|---|---|---|
| Walking, GPS available | 2 (walk) | 2 (walk) | 1 (walk) | 5 walk | High |
| Driving at 60 km/h | 3 (drive) | 1 (drive) | 1 (drive) | 5 drive | High |
| Traffic jam (5 km/h) | 2 (walk) | 1 (drive) | 1 (drive) | 2 walk, 2 drive | Low β tie |
| No GPS, walking | β | 2 (walk) | 1 (walk) | 3 walk | Medium |
| Running | 2 (run) | 2 (run) | 1 (walk) | 4 run, 1 walk | Medium-high |
Notice the traffic jam tie case: score is 2 each, resulting in low confidence and potentially .unknown. This is the correct behaviour β we genuinely don't know. Adding the CMMotionActivityManager high-level classification as a tie-breaker is a recommended enhancement (see Section 11).
6.9 Wiring it into a ViewController
import UIKit
class ActivityViewController: UIViewController {
private let detector = ActivityDetector()
// ββ UI outlets ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
@IBOutlet private weak var activityLabel: UILabel!
@IBOutlet private weak var confidenceLabel: UILabel!
@IBOutlet private weak var speedLabel: UILabel!
@IBOutlet private weak var cadenceLabel: UILabel!
@IBOutlet private weak var varianceLabel: UILabel!
@IBOutlet private weak var timestampLabel: UILabel!
// ββ Lifecycle βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
override func viewDidLoad() {
super.viewDidLoad()
configureDetector()
detector.startDetecting()
}
deinit {
detector.stopDetecting()
}
// ββ Setup βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
private func configureDetector() {
detector.onActivityDetected = { [weak self] result in
// Callback is already on main thread
self?.updateUI(with: result)
self?.handleActivityChange(result)
}
}
// ββ UI update βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
private func updateUI(with result: ActivityResult) {
activityLabel.text = result.type.rawValue
confidenceLabel.text = "Confidence: \(result.confidence)"
if result.speed >= 0 {
speedLabel.text = String(format: "Speed: %.1f km/h", result.speedKMH)
} else {
speedLabel.text = "Speed: GPS unavailable"
}
cadenceLabel.text = String(format: "Cadence: %.2f steps/s", result.stepFrequency)
varianceLabel.text = String(format: "Variance: %.4f", result.accelerationVariance)
let formatter = DateFormatter()
formatter.timeStyle = .medium
timestampLabel.text = "Updated: \(formatter.string(from: result.timestamp))"
}
// ββ Application logic βββββββββββββββββββββββββββββββββββββββββββββββββββββ
private func handleActivityChange(_ result: ActivityResult) {
guard result.confidence != "low" else { return }
switch result.type {
case .driving:
enableDrivingMode()
case .walking, .running:
enablePedestrianMode()
case .stationary:
enableStationaryMode()
default:
break
}
}
private func enableDrivingMode() {
// Example: suppress notifications, switch to audio-only interface
print("Driving mode activated")
}
private func enablePedestrianMode() {
// Example: switch to walking navigation, show nearby POIs
print("Pedestrian mode activated")
}
private func enableStationaryMode() {
// Example: offer suggestions based on location
print("Stationary mode activated")
}
}
7. Scenario Walkthroughs
7.1 Scenario A: Normal sidewalk walk
Context: User walking to work at 1.5 m/s on a clear day, phone in jacket pocket.
| Signal | Value | Vote |
|---|---|---|
| GPS speed | 1.5 m/s (valid, accuracy Β±0.3 m/s) | +2 walking |
| Cadence | 1.7 steps/sec | +2 walking |
| Accel variance | 0.045 | +1 walking |
| Total | 5 walking β High confidence |
CMMotionActivityManager fires .walking with .high confidence simultaneously.
Result: ActivityType.walking, confidence "high" β
7.2 Scenario B: Driving on a motorway
Context: User driving at 110 km/h (30.5 m/s) on a motorway, phone on dashboard mount.
| Signal | Value | Vote |
|---|---|---|
| GPS speed | 30.5 m/s (valid) | +3 driving |
| Cadence | 0.0 steps/sec | +1 driving |
| Accel variance | 0.008 (smooth tarmac) | +1 driving |
| Total | 5 driving β High confidence |
Result: ActivityType.driving, confidence "high" β
7.3 Scenario C: Sitting in a slow-moving traffic jam
Context: Car moving at 0.8 m/s through city traffic, phone in cupholder.
| Signal | Value | Vote |
|---|---|---|
| GPS speed | 0.8 m/s | +2 walking (below walkingSpeedMax of 2.2) |
| Cadence | 0.0 steps/sec | +1 driving |
| Accel variance | 0.015 (small bumps) | borderline β 0 votes |
| Total | 2 walking, 1 driving β Low confidence |
Result: Type could be walking or driving; confidence "low". This is expected β GPS is ambiguous. The CMMotionActivityManager should return .automotive here (the coprocessor's HMM knows the user was driving before entering the jam) and can be used as a tie-breaker.
Recommended enhancement: Add a fourth vote source from CMMotionActivityManager:
// In the fusion classifier, add:
if let lastCoprocessorActivity = lastCoprocessorActivity {
if lastCoprocessorActivity.automotive {
votes[.driving, default: 0] += 2
} else if lastCoprocessorActivity.walking {
votes[.walking, default: 0] += 2
}
}
7.4 Scenario D: Passenger in a car (not driving)
From a sensor perspective, a passenger is indistinguishable from a driver. Both are inside an automotive vehicle, both show near-zero cadence, and GPS speed reflects the vehicle speed. This is a fundamental limitation of the approach described here.
To distinguish driver from passenger requires additional signals:
- Bluetooth/CarPlay connection β if the phone is connected to the car's infotainment system, it's likely the driver's phone.
- Steering wheel pattern in gyroscope β the driver's phone (in a cup holder or mount near the steering wheel) shows micro-rotational signals correlated with steering.
- Seat position β not accessible to apps without additional hardware.
Apple's own "Driving Focus" uses CarPlay connection and Bluetooth as the primary discriminator.
7.5 Scenario E: Running for a bus
Context: User sprinting at 4.5 m/s for 15 seconds, then standing at a bus stop.
During the sprint:
| Signal | Value | Vote |
|---|---|---|
| GPS speed | 4.5 m/s | +2 running |
| Cadence | 3.2 steps/sec | +2 running |
| Accel variance | 0.22 | +1 walking |
| Total | 4 running, 1 walking β Medium confidence running |
At the bus stop (after 5 seconds standing):
| Signal | Value | Vote |
|---|---|---|
| GPS speed | 0.1 m/s (GPS noise at standstill) | +2 walking |
| Cadence | 0.0 | +1 driving |
| Accel variance | 0.003 (very still) | +1 driving |
| Total | 2 walking, 2 driving β Low confidence (tie) |
The tie at the bus stop can be resolved by the coprocessor (which will return .stationary after a few seconds of no movement) or by a temporal hysteresis filter that prevents rapid transitions.
7.6 Scenario F: GPS-denied environment (underground car park)
Context: User walking through an underground car park. GPS unavailable, speed returns -1.
| Signal | Value | Vote |
|---|---|---|
| GPS speed | -1 (invalid) | No votes cast |
| Cadence | 1.6 steps/sec | +2 walking |
| Accel variance | 0.040 | +1 walking |
| Total | 3 walking β Medium confidence |
GPS votes are skipped entirely (speed >= 0 check fails). The system correctly falls back to the motion-only signals. Medium confidence reflects the missing GPS corroboration.
This demonstrates why multi-signal fusion is essential: GPS-only classification would produce no output at all in this scenario.
7.7 Scenario G: Cycling
Context: User cycling at 6 m/s on a bike path, phone in jersey pocket.
| Signal | Value | Vote |
|---|---|---|
| GPS speed | 6.0 m/s | Exactly at drivingSpeedThreshold β votes +3 driving |
| Cadence | 0.0 steps/sec (no footsteps on pedals) | +1 driving |
| Accel variance | 0.018 (smooth bike path) | borderline β 0 votes |
| Total | 4 driving β Medium confidence |
This is a known misclassification: cycling at road speed is mistaken for driving. The CMMotionActivityManager returns .cycling here, which can override the fusion result. This highlights the value of including the coprocessor result as a voting signal.
Enhanced fusion including coprocessor:
// Add to fuseSignals:
if let coprocessorType = lastCoprocessorClassification {
switch coprocessorType {
case .cycling:
votes[.cycling, default: 0] += 3 // Trust the coprocessor on cycling
case .driving:
votes[.driving, default: 0] += 2
case .walking:
votes[.walking, default: 0] += 2
default:
break
}
}
7.8 Scenario H: Treadmill walking
Context: User walking on a treadmill at 5 km/h. GPS speed: effectively 0 (not moving through space).
| Signal | Value | Vote |
|---|---|---|
| GPS speed | ~0.1 m/s (GPS noise) | +2 walking |
| Cadence | 1.6 steps/sec | +2 walking |
| Accel variance | 0.035 | +1 walking |
| Total | 5 walking β High confidence |
The treadmill case is handled correctly because GPS is not needed β cadence and variance alone confirm walking. The Health app also correctly logs treadmill walks as walking distance (using step count + stride length rather than GPS displacement).
8. Edge Cases and Known Failure Modes
False walking during car vibration
On cobblestone streets, gravel roads, or over expansion joints on bridges, a car can produce accelerometer variance of 0.05β0.15 β overlapping with the walking range. The cadence signal will remain at zero (no steps), which should prevent misclassification if weighted properly. Ensure cadence has higher weight than variance in your fusion logic.
False driving during wheelchair use
A manual wheelchair user at normal push speed (~1β2 m/s) has near-zero cadence (the arms push, not walk) and may have low accelerometer variance on a smooth floor. GPS correctly reads walking speed, but cadence and variance both vote "driving/stationary". The CMMotionActivityManager typically returns .stationary for wheelchair users on smooth floors, which is technically incorrect.
If your app needs to support wheelchair users, consider adding an explicit wheelchair mode (as Apple's Health app does) with separate thresholds.
False stationary during slow dance or yoga
Some yoga poses involve holding the body still for seconds. Between transitions, the sensor profile can briefly match "stationary". This is usually benign (the transition lasts 1β2 seconds) but can cause flickers in real-time display. Hysteresis (Section 11.3) is the remedy.
Running at low cadence (injury or age)
Elderly users and some injury-recovery patients may run or jog at cadences below 2.5 steps/sec. The classifier would label these as "walking" β usually acceptable since the distinction is minor for most applications.
Child in pushchair
A child's phone in a pushchair shows: GPS at walking speed, near-zero cadence, and moderate variance from the pushchair's vibration. Classification correctly returns "walking" from the parent's perspective (the pushchair is being propelled by a walking adult). The child's own motion (sitting still) is not separately classifiable.
Elevator
Vertical motion (elevator) produces a brief acceleration spike when the elevator starts and stops, and a short period of near-zero horizontal displacement. GPS may not update (indoors). The system typically classifies elevator rides as "stationary" β correct enough for most applications, but CMDeviceMotion.userAcceleration on the Y-axis can detect the vertical acceleration if needed.
9. Real-World Use Cases and Applications
Navigation apps (Apple Maps, Google Maps, Waze)
These apps switch the UI, route type, and turn-by-turn style automatically based on detected activity. When the system detects walking, it may:
- Show pedestrian routing (footpaths, crosswalks)
- Reduce map zoom to show more detail at the walking scale
- Announce turns in seconds/distance rather than motorway-style distance markers
Driving Focus / Do Not Disturb While Driving
iOS's built-in Driving Focus uses the motion stack to automatically enable notification suppression. It responds to CMMotionActivityManager returning .automotive with high confidence, optionally combined with CarPlay/Bluetooth detection.
Fitness and health tracking (Apple Health, Strava, Garmin Connect)
Walking and running activity detected by CMPedometer accumulates in HealthKit under HKQuantityType.stepCount, HKQuantityType.distanceWalkingRunning, and HKQuantityType.activeEnergyBurned. Apps can query and write these quantities:
import HealthKit
let store = HKHealthStore()
let stepType = HKQuantityType.quantityType(forIdentifier: .stepCount)!
let query = HKStatisticsQuery(
quantityType: stepType,
quantitySamplePredicate: nil,
options: .cumulativeSum
) { _, result, error in
let steps = result?.sumQuantity()?.doubleValue(for: .count()) ?? 0
print("Total steps: \(steps)")
}
store.execute(query)
Insurance telematics
Several car insurance providers (Progressive Snapshot, Allstate Drivewise) use smartphone sensors to score driving behaviour. Activity detection ensures the telematics app only analyses driving sessions and ignores walking or public transport. Accelerometer data during confirmed driving sessions is used to detect harsh braking (large negative acceleration spike), rapid cornering (lateral acceleration), and speeding (GPS speed above limit).
Automatic mileage logging
Apps like MileIQ, Driversnote, and Everlance automatically log vehicle trips for expense reporting or tax deduction. Activity detection is the trigger: when the transition from walking to driving is detected, a new trip record begins. When driving transitions to walking (arriving at a destination), the trip ends and a GPS track is stored.
Adaptive UI for accessibility
Apps aimed at elderly users can use activity detection to adjust interface behaviour:
- When walking: increase notification urgency (user may not check phone)
- When stationary: show detailed interactive content
- When driving: suppress all non-critical interruptions
Location-based services optimisation
Continuous GPS use at kCLLocationAccuracyBest drains ~20β30 mA. An app can use activity detection to reduce GPS accuracy when walking or stationary:
detector.onActivityDetected = { [weak self] result in
guard let self = self else { return }
switch result.type {
case .driving:
// High accuracy needed for navigation
self.locationManager.desiredAccuracy = kCLLocationAccuracyBest
self.locationManager.distanceFilter = 5
case .walking:
// Moderate accuracy sufficient
self.locationManager.desiredAccuracy = kCLLocationAccuracyNearestTenMeters
self.locationManager.distanceFilter = 10
case .stationary:
// Minimal accuracy saves battery
self.locationManager.desiredAccuracy = kCLLocationAccuracyHundredMeters
self.locationManager.distanceFilter = 50
default:
break
}
}
Geofencing smarter triggers
Standard CLCircularRegion geofences fire based on GPS entry/exit regardless of activity. Combining with activity detection enables smarter triggers: "notify when the user arrives at work and is walking (i.e. they've parked and are approaching on foot)" rather than firing while still in the car.
10. Battery and Performance Considerations
| Component | Approximate current drain | Notes |
|---|---|---|
| CMMotionActivityManager | ~0.1 mA | Runs on coprocessor; negligible |
| CMPedometer | ~0.1 mA | Coprocessor-assisted |
| CMMotionManager (50 Hz) | ~0.5β1 mA | Main CPU required; stops in background |
| CLLocationManager (Best accuracy) | ~20β30 mA | Highest drain; use only when needed |
| CLLocationManager (100m accuracy) | ~3β5 mA | Acceptable for coarse activity |
| Total (full stack) | ~22β32 mA | GPS dominates |
Practical battery-saving strategies:
Use CMMotionActivityManager first. If you only need to know "is the user driving or walking?" at a coarse level, CMMotionActivityManager alone costs essentially nothing. Add GPS only when you need precise speed or route data.
Adaptive GPS accuracy (shown in Section 9 above). Reduce GPS accuracy when the user is not driving.
Stop the raw accelerometer when backgrounded. CMMotionManager stops automatically when the app backgrounds. If you need background variance analysis, consider using CMPedometer.queryPedometerData(from:to:withHandler:) batch queries rather than live streaming.
Batch historical queries instead of live streaming. For apps that analyse yesterday's activity pattern (e.g., a fitness summary), use queryActivityStarting(from:to:to:withHandler:) rather than streaming all day.
Implement significantLocationChangeMonitoring for coarse triggers:
locationManager.startMonitoringSignificantLocationChanges()
// Fires only when cell tower changes (~500m accuracy, ~1 mA)
// Enough to detect a major travel context change
11. Improving Accuracy: Advanced Techniques
11.1 Kalman filtering for GPS speed smoothing
Raw GPS speed can jump between updates due to multipath errors. A simple 1D Kalman filter smooths these spikes:
class KalmanFilter {
private var estimate: Double
private var errorCovariance: Double
private let processNoise: Double // How much we trust the model
private let measurementNoise: Double // How much we trust the sensor
init(initial: Double, processNoise: Double = 0.1, measurementNoise: Double = 1.0) {
self.estimate = initial
self.errorCovariance = 1.0
self.processNoise = processNoise
self.measurementNoise = measurementNoise
}
func update(measurement: Double) -> Double {
// Predict
let predictedCovariance = errorCovariance + processNoise
// Update (Kalman gain)
let gain = predictedCovariance / (predictedCovariance + measurementNoise)
estimate = estimate + gain * (measurement - estimate)
errorCovariance = (1 - gain) * predictedCovariance
return estimate
}
}
// Usage
let speedFilter = KalmanFilter(initial: 0, processNoise: 0.05, measurementNoise: 0.5)
// In location update handler:
let rawSpeed = max(0, location.speed)
let filteredSpeed = speedFilter.update(measurement: rawSpeed)
Tuning the filter:
- Higher
processNoiseβ filter trusts speed changes more (less smoothing, faster response) - Higher
measurementNoiseβ filter trusts GPS less (more smoothing, slower response) - For walking:
processNoise = 0.05,measurementNoise = 0.3(walking speed changes slowly) - For driving:
processNoise = 0.5,measurementNoise = 0.5(speed changes more frequently)
11.2 Frequency-domain analysis (FFT) on accelerometer data
The walking frequency (~1.4β2.5 Hz) is distinct from driving vibration frequencies (engine ~10β100 Hz, road rumble ~5β20 Hz). A Fast Fourier Transform on the accelerometer magnitude signal can identify these frequency bands and provide a much cleaner classifier.
import Accelerate
func dominantFrequency(samples: [Double], sampleRate: Double) -> Double {
let n = samples.count
let log2n = vDSP_Length(log2(Double(n)))
guard let fftSetup = vDSP_create_fftsetupD(log2n, FFTRadix(kFFTRadix2)) else {
return 0
}
defer { vDSP_destroy_fftsetupD(fftSetup) }
// Set up complex buffer
var realParts = samples
var imagParts = [Double](repeating: 0, count: n)
var splitComplex = DSPDoubleSplitComplex(
realp: &realParts,
imagp: &imagParts
)
// Execute FFT
vDSP_fft_zipD(fftSetup, &splitComplex, 1, log2n, FFTDirection(kFFTDirection_Forward))
// Compute magnitudes
var magnitudes = [Double](repeating: 0, count: n / 2)
vDSP_zvmagsD(&splitComplex, 1, &magnitudes, 1, vDSP_Length(n / 2))
// Find peak frequency (excluding DC component at index 0)
let peakIndex = magnitudes[1...].enumerated().max(by: { $0.element < $1.element })?.offset ?? 0
let peakFrequency = Double(peakIndex + 1) * sampleRate / Double(n)
return peakFrequency
}
// Walking peak: 1.4β2.5 Hz
// Driving peak: often 10β80 Hz from engine/road
// If peakFrequency is between 1.0 and 3.0: strong walking indicator
This approach is more computationally expensive but dramatically reduces false positives from bumpy roads. The Accelerate framework's vDSP FFT runs efficiently on the iPhone's NEON SIMD unit.
11.3 Hysteresis to prevent rapid mode-switching
Without hysteresis, a user stopping at a red light can trigger multiple walkingβdriving transitions per second as GPS fluctuates around the threshold. Hysteresis requires that a new classification be sustained for a minimum duration before committing to it:
class HystereticActivityFilter {
private var currentType: ActivityType = .unknown
private var pendingType: ActivityType = .unknown
private var pendingStartTime: Date?
/// Minimum duration (seconds) a new activity must be consistently detected
/// before we commit to it.
let hysteresisWindow: TimeInterval
init(hysteresisWindow: TimeInterval = 5.0) {
self.hysteresisWindow = hysteresisWindow
}
/// Feed in a new raw classification. Returns the stable classification.
func filter(rawType: ActivityType) -> ActivityType {
let now = Date()
if rawType == currentType {
// Consistent with current state β reset pending
pendingType = rawType
pendingStartTime = nil
return currentType
}
if rawType == pendingType {
// Same as pending β check if it's been long enough
if let startTime = pendingStartTime,
now.timeIntervalSince(startTime) >= hysteresisWindow {
currentType = rawType
pendingType = rawType
pendingStartTime = nil
}
} else {
// New type β start the hysteresis timer
pendingType = rawType
pendingStartTime = now
}
return currentType
}
}
// Usage
let hystFilter = HystereticActivityFilter(hysteresisWindow: 5.0)
detector.onActivityDetected = { result in
let stableType = hystFilter.filter(rawType: result.type)
print("Stable activity: \(stableType)")
}
A 5-second window is appropriate for most applications. For driving mode detection specifically (where a false disable while stopped at a light is very disruptive), 10β15 seconds is more comfortable.
11.4 Machine learning with Create ML and CoreML
Apple's Create ML (Create ML.app in Xcode) includes an Activity Classifier template that accepts time-series sensor data and produces a CoreML model you can bundle into your app.
Data preparation:
Dataset structure:
data/
walking/
session_001.csv (timestamp, ax, ay, az, gx, gy, gz)
session_002.csv
...
driving/
session_001.csv
...
running/
session_001.csv
...
Each CSV row: a timestamp plus 6 columns of accelerometer and gyroscope data at a fixed sample rate (typically 50 Hz).
Training in Create ML:
- Open Create ML app in Xcode
- Create new Activity Classifier project
- Set prediction window size: 50 samples (1 second at 50 Hz)
- Drag in your labelled data folders
- Train β Create ML automatically extracts features (mean, variance, FFT peaks, etc.)
- Export the
.mlmodelfile
Using the model in your app:
import CoreML
// The generated class name comes from your Create ML project name
let model = try! ActivityClassifier(configuration: MLModelConfiguration())
// Collect 50 samples (1 second at 50 Hz) into a feature provider
// Create ML expects a MLMultiArray with shape [window, channels]
let windowSize = 50
var accelWindow: [[Double]] = [] // collect samples...
// Create MLMultiArray
let inputArray = try! MLMultiArray(
shape: [NSNumber(value: windowSize), 6], // 6 channels: ax,ay,az,gx,gy,gz
dataType: .double
)
// Fill the array with your window of samples
for (i, sample) in accelWindow.enumerated() {
for (j, value) in sample.enumerated() {
inputArray[[NSNumber(value: i), NSNumber(value: j)]] = NSNumber(value: value)
}
}
// Run inference
let input = ActivityClassifierInput(features: inputArray, stateIn: model.stateIn)
let output = try! model.prediction(input: input)
print("Predicted activity: \(output.label)")
print("Probabilities: \(output.labelProbability)")
The CoreML model runs at under 1ms per inference on modern iPhones using the Neural Engine. This approach can achieve >95% accuracy on the walking/driving distinction across a diverse population when trained on sufficient data (>1000 labelled sessions per class).
12. Privacy, Permissions, and App Store Compliance
Required permission descriptions
At minimum, your Info.plist must include:
<!-- Motion data β required for CMMotionActivityManager, CMPedometer, CMMotionManager -->
<key>NSMotionUsageDescription</key>
<string>App name uses motion data to detect whether you are walking or driving,
adapting navigation guidance to your current mode of transport.</string>
<!-- Location β required for CLLocationManager -->
<key>NSLocationWhenInUseUsageDescription</key>
<string>App name uses your location while open to provide speed-aware activity
detection and accurate journey tracking.</string>
If you use requestAlwaysAuthorization() (for background detection):
<key>NSLocationAlwaysAndWhenInUseUsageDescription</key>
<string>Background location access allows App name to automatically start and stop
journey logging without requiring the app to be open.</string>
What data is collected and how to explain it to users
Apple's App Privacy framework requires you to declare in App Store Connect:
- Precise location: Yes, if you use
kCLLocationAccuracyBest - Location (not precise): Yes, if you use coarser accuracy
- Motion & Fitness: Yes, if you use
CMMotionActivityManagerorCMPedometer - Usage data: Depends on whether you log activity to your own servers
Best practice: Do not send raw sensor data to your servers. Process everything on-device. Only transmit derived labels (e.g., "trip from A to B, 15 minutes driving") rather than raw GPS tracks or accelerometer data.
App Store review considerations
Apple's reviewer guidelines (4.1, 5.1.1) require that location and motion use be "directly relevant to the core functionality" of the app. Activity detection to switch navigation modes is clearly justified. Using motion data purely for advertising targeting would not be approved.
The NSMotionUsageDescription string is shown to the user in the system permission prompt. Make it clear and honest β vague strings like "Used for app functionality" will result in App Store rejection.
13. Testing Strategy
Unit testing the fusion classifier
The fuseSignals function is a pure function (no side effects, no I/O) and is easily unit-tested:
import XCTest
@testable import YourApp
class ActivityDetectorTests: XCTestCase {
var detector: ActivityDetector!
override func setUp() {
detector = ActivityDetector()
}
func testHighwayDrivingDetected() {
let (type, confidence) = detector.fuseSignals(
speed: 30.0, // 108 km/h
stepFrequency: 0.0,
accelVariance: 0.008
)
XCTAssertEqual(type, .driving)
XCTAssertEqual(confidence, "high")
}
func testNormalWalkingDetected() {
let (type, confidence) = detector.fuseSignals(
speed: 1.5,
stepFrequency: 1.7,
accelVariance: 0.045
)
XCTAssertEqual(type, .walking)
XCTAssertEqual(confidence, "high")
}
func testTrafficJamIsAmbiguous() {
let (_, confidence) = detector.fuseSignals(
speed: 0.8, // Could be walk or very slow drive
stepFrequency: 0.0,
accelVariance: 0.015
)
XCTAssertEqual(confidence, "low")
}
func testGPSUnavailableStillClassifiesWalking() {
let (type, confidence) = detector.fuseSignals(
speed: -1.0, // GPS invalid
stepFrequency: 1.6,
accelVariance: 0.040
)
XCTAssertEqual(type, .walking)
XCTAssertEqual(confidence, "medium") // Only 2 signals agree, so medium not high
}
func testRunningDetected() {
let (type, _) = detector.fuseSignals(
speed: 4.0,
stepFrequency: 3.1,
accelVariance: 0.180
)
XCTAssertEqual(type, .running)
}
}
Integration testing on a real device
Simulators cannot simulate motion sensor data in a meaningful way for activity detection. All integration testing must be done on physical iPhones. Recommended test procedure:
- Walking test: Walk around a building exterior for 2 minutes; verify consistent walking detection with high confidence.
- Driving test: Drive on a suburban road for 5 minutes including stops at traffic lights; verify driving detection survives the stops.
- Transition test: Walk to a car, get in, start driving; measure transition detection latency.
- GPS-denied test: Walk through a shopping centre or car park; verify fallback to motion-only classification.
- Pocket vs hand test: Repeat walking test with phone in various positions; verify orientation invariance.
Using simulated GPS data for repeatable tests
For CI pipelines, you can inject GPX routes as simulated location data in the iOS Simulator:
Xcode β Debug β Simulate Location β Add GPX File to Project
Or inject programmatically using XCTest:
// In a UI test target
let app = XCUIApplication()
app.launchArguments.append("-simulateLocation")
app.launchArguments.append("walkingRoute.gpx")
14. Broader Perspectives: How Other Platforms Approach This
Android β Activity Recognition API
Google's Activity Recognition API (part of Google Play Services) offers a similar high-level interface:
val client = ActivityRecognition.getClient(context)
val intent = Intent(context, ActivityTransitionReceiver::class.java)
val transitions = listOf(
ActivityTransition.Builder()
.setActivityType(DetectedActivity.WALKING)
.setActivityTransitionType(ActivityTransition.ACTIVITY_TRANSITION_ENTER)
.build()
)
val request = ActivityTransitionRequest(transitions)
client.requestActivityTransitionUpdates(request, pendingIntent)
Google classifies into: IN_VEHICLE, ON_BICYCLE, ON_FOOT, RUNNING, WALKING, STILL, TILTING, UNKNOWN. Like Apple, Google uses a combination of accelerometer, gyroscope, and GPS.
Key difference: Google's classifier runs partly server-side on older Android devices (to compensate for less capable hardware), introducing a network dependency that Apple avoids by running everything on the M-series coprocessor.
Wear OS / Apple Watch
Apple Watch adds significant discriminating capability:
- Wrist accelerometer directly measures arm swing (a key walking signal) rather than relying on in-pocket/in-bag phone motion.
- Heart rate sensor distinguishes exercise walking (elevated HR) from casual walking.
- Optical heart rate correlation with step frequency provides a cleaner cadence signal than the phone accelerometer alone.
- Gyroscope can distinguish walking arm swing from car passenger hand-arm motion.
The WKInterfaceController and CMMotionManager on watchOS follow the same APIs as iOS, with the benefit of more reliable placement (wrist vs arbitrary body location).
Dedicated activity trackers (Garmin, Fitbit)
Purpose-built fitness trackers solve the placement problem by guaranteeing wrist placement. They use simpler algorithms (step counting + threshold on cadence and GPS speed) because the accelerometer position is consistent. Without GPS (Fitbit Charge), pure accelerometer step counting has ~95% accuracy for walking vs stationary.
Research approaches: beyond rule-based classifiers
Academic literature (2015β2024) has explored several techniques beyond the rule-based approach described here:
Deep learning on raw sensor data. Convolutional neural networks applied directly to 2-second windows of raw accelerometer data (at 50 Hz) achieve 97β99% accuracy on the standard HAPT (Human Activity and Postural Transitions) benchmark dataset. The key advantage is automatic feature extraction β the network learns what features matter from data, eliminating hand-tuned thresholds.
Transformer-based models. Self-attention mechanisms applied to IMU time series outperform LSTMs on ambiguous transitions (slow run vs fast walk) by capturing long-range temporal dependencies.
Federated learning. Training activity classifiers across millions of devices without centralising sensitive sensor data. Apple's Core ML and on-device training infrastructure supports this pattern.
Multi-modal fusion with neural networks. Rather than rule-based voting, a fusion network takes all sensor streams as input and learns the optimal fusion weights end-to-end. This handles correlated signals and non-linear interactions that rule-based systems miss.
15. References and Further Reading
Apple Developer Documentation
CMMotionActivityManagerβ https://developer.apple.com/documentation/coremotion/cmmotionactivitymanagerCMPedometerβ https://developer.apple.com/documentation/coremotion/cmpedometerCMMotionManagerβ https://developer.apple.com/documentation/coremotion/cmmotionmanagerCLLocationManagerβ https://developer.apple.com/documentation/corelocation/cllocationmanagerCLLocation.speedandspeedAccuracyβ https://developer.apple.com/documentation/corelocation/cllocation/speed- Create ML Activity Classifier β https://developer.apple.com/documentation/createml/creating_an_activity_classifier_model
WWDC Sessions
- WWDC 2014 Session 612 β "Core Motion" β Introduced CMMotionActivityManager and the M7 coprocessor
- WWDC 2019 Session 705 β "Advances in Core Motion and Motion Tracking"
- WWDC 2021 Session 10054 β "Create ML for activity classification"
- WWDC 2023 Session 10044 β "Explore improvements in CoreMotion"
Academic Papers
- Kwapisz, J.R., Weiss, G.M., & Moore, S.A. (2011). "Activity recognition using cell phone accelerometers." ACM SIGKDD Explorations. β One of the foundational papers on smartphone-based activity recognition.
- Anjum, A., & Ilyas, M.U. (2013). "Activity recognition using smartphone sensors." IEEE CCNC. β Established the threshold ranges for walking vs driving that inform implementations like this one.
- Chen, Z., et al. (2021). "Deep learning for sensor-based activity recognition." Information Fusion. β Survey of neural network approaches.
- Demrozi, F., et al. (2020). "Human activity recognition using inertial, physiological and environmental sensors." IEEE Access.
Open Datasets for Training and Benchmarking
- WISDM (Wireless Sensor Data Mining Lab) β labelled accelerometer data for walking, jogging, sitting, standing, upstairs, downstairs. http://www.cis.fordham.edu/wisdm/dataset.php
- UCI HAPT (Human Activity and Postural Transitions) β smartphone sensor data at 50 Hz with 12 activity labels including walking, walking upstairs, sitting, standing. UCI Machine Learning Repository.
- ExtraSensory β 300,000+ minutes of labelled data from real-world free-living settings. http://extrasensory.ucsd.edu
Related iOS APIs
HKWorkoutSessionβ HealthKit workout tracking integrated with activity detectionCMFallDetectionManagerβ fall detection using activity context (requires Apple Watch Ultra/Series 4+)CLBeaconRegionβ indoor positioning using Bluetooth beacons (complements GPS-denied detection)WKExtendedRuntimeSessionβ watchOS background session for continuous activity monitoring
Document version 1.0 β Last updated June 2026 Swift version: 5.9+ | iOS deployment target: iOS 16+ | Xcode 15+