Source: ios_sensors Β· ios_sensors.md Β· updated 2026-06-22 Β· πŸ”’ secret gist

Synced 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

  1. The Problem: Why GPS Alone Is Not Enough
  2. The Hardware Layer: Sensors Inside the iPhone
  3. The Framework Layer: Apple's CoreMotion & CoreLocation
  4. Signal Characteristics: What Each Activity Looks Like
  5. The Physics and Mathematics Behind Each Signal
  6. Full Implementation Walkthrough
  7. Scenario Walkthroughs
  8. Edge Cases and Known Failure Modes
  9. Real-World Use Cases and Applications
  10. Battery and Performance Considerations
  11. Improving Accuracy: Advanced Techniques
  12. Privacy, Permissions, and App Store Compliance
  13. Testing Strategy
  14. Broader Perspectives: How Other Platforms Approach This
  15. 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:

RateTypical use
10 HzBackground step detection, coarse activity
50 HzReal-time activity classification (our use case)
100 HzGesture 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 CMDeviceMotion API 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 date
  • currentPace β€” current pace in seconds per metre (⚠ not per step)
  • currentCadence β€” steps per second (iOS 9+)
  • distance β€” estimated walking distance in metres
  • floorsAscended / 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

ActivityTypical speed rangeNotes
Stationary0–0.3 m/sGPS noise floor; readings oscillate even when standing still
Slow walk0.5–1.2 m/sOlder adults, browsing on a phone
Normal walk1.2–1.8 m/sAverage adult pace (~4.5 km/h)
Brisk walk1.8–2.2 m/sCommuter pace
Running (slow jog)2.2–4.0 m/s8–14 min/km
Running (fast)4.0–7.0 m/sSub-8 min/km
Cycling (slow)3.0–5.0 m/sUrban cycling
Cycling (fast)5.0–12.0 m/sRoad cycling
Traffic jam driving0–2.0 m/sOverlap with walking range
Urban driving6.0–14.0 m/s22–50 km/h
Motorway driving22–40+ m/s80–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

ActivitySteps/secondNotes
Stationary0Occasional fidget may produce 0.1–0.3 briefly
Strolling0.8–1.2Window shopping, corridors
Normal walk1.4–1.8Most adults
Brisk walk1.8–2.5Exercise walking
Running2.5–4.0Increases with speed
Sprint4.0–5.0Elite sprinters up to 5.5
Cycling0No footsteps (pedalling doesn't register)
Driving0No 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.

ActivityTypical varianceExplanation
Stationary on table<0.001Essentially no motion
Held stationary0.002–0.010Micro hand tremors
Driving (smooth motorway)0.005–0.020Engine vibration, smooth road
Driving (city with potholes)0.020–0.100Road surface irregularities
Walking (in pocket)0.020–0.080Hip-transmitted step bounce
Walking (in hand)0.050–0.200Arm swing adds to hip movement
Running0.100–0.400High-impact footstrikes
Cycling0.010–0.050Smooth 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:

SignalWalkingDrivingRunningCyclingStationary
GPS speed (m/s)0.5–2.2>6 (usually >10)2.2–73–120–0.3
Cadence (steps/s)1.2–2.5~02.5–5~0~0
Accel variance0.02–0.20.005–0.050.1–0.40.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:

  1. Low-pass filtering to remove high-frequency noise (the coprocessor does this in hardware)
  2. Finding local maxima above a threshold (~1.2 g for the magnitude)
  3. 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:

ScenarioGPS votesCadence votesVar votesTotalConfidence
Walking, GPS available2 (walk)2 (walk)1 (walk)5 walkHigh
Driving at 60 km/h3 (drive)1 (drive)1 (drive)5 driveHigh
Traffic jam (5 km/h)2 (walk)1 (drive)1 (drive)2 walk, 2 driveLow β€” tie
No GPS, walkingβ€”2 (walk)1 (walk)3 walkMedium
Running2 (run)2 (run)1 (walk)4 run, 1 walkMedium-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.

SignalValueVote
GPS speed1.5 m/s (valid, accuracy Β±0.3 m/s)+2 walking
Cadence1.7 steps/sec+2 walking
Accel variance0.045+1 walking
Total5 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.

SignalValueVote
GPS speed30.5 m/s (valid)+3 driving
Cadence0.0 steps/sec+1 driving
Accel variance0.008 (smooth tarmac)+1 driving
Total5 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.

SignalValueVote
GPS speed0.8 m/s+2 walking (below walkingSpeedMax of 2.2)
Cadence0.0 steps/sec+1 driving
Accel variance0.015 (small bumps)borderline β€” 0 votes
Total2 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:

SignalValueVote
GPS speed4.5 m/s+2 running
Cadence3.2 steps/sec+2 running
Accel variance0.22+1 walking
Total4 running, 1 walking β€” Medium confidence running

At the bus stop (after 5 seconds standing):

SignalValueVote
GPS speed0.1 m/s (GPS noise at standstill)+2 walking
Cadence0.0+1 driving
Accel variance0.003 (very still)+1 driving
Total2 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.

SignalValueVote
GPS speed-1 (invalid)No votes cast
Cadence1.6 steps/sec+2 walking
Accel variance0.040+1 walking
Total3 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.

SignalValueVote
GPS speed6.0 m/sExactly at drivingSpeedThreshold β€” votes +3 driving
Cadence0.0 steps/sec (no footsteps on pedals)+1 driving
Accel variance0.018 (smooth bike path)borderline β€” 0 votes
Total4 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).

SignalValueVote
GPS speed~0.1 m/s (GPS noise)+2 walking
Cadence1.6 steps/sec+2 walking
Accel variance0.035+1 walking
Total5 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

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

ComponentApproximate current drainNotes
CMMotionActivityManager~0.1 mARuns on coprocessor; negligible
CMPedometer~0.1 mACoprocessor-assisted
CMMotionManager (50 Hz)~0.5–1 mAMain CPU required; stops in background
CLLocationManager (Best accuracy)~20–30 mAHighest drain; use only when needed
CLLocationManager (100m accuracy)~3–5 mAAcceptable for coarse activity
Total (full stack)~22–32 mAGPS 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:

  1. Open Create ML app in Xcode
  2. Create new Activity Classifier project
  3. Set prediction window size: 50 samples (1 second at 50 Hz)
  4. Drag in your labelled data folders
  5. Train β€” Create ML automatically extracts features (mean, variance, FFT peaks, etc.)
  6. Export the .mlmodel file

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 CMMotionActivityManager or CMPedometer
  • 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:

  1. Walking test: Walk around a building exterior for 2 minutes; verify consistent walking detection with high confidence.
  2. Driving test: Drive on a suburban road for 5 minutes including stops at traffic lights; verify driving detection survives the stops.
  3. Transition test: Walk to a car, get in, start driving; measure transition detection latency.
  4. GPS-denied test: Walk through a shopping centre or car park; verify fallback to motion-only classification.
  5. 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/cmmotionactivitymanager
  • CMPedometer β€” https://developer.apple.com/documentation/coremotion/cmpedometer
  • CMMotionManager β€” https://developer.apple.com/documentation/coremotion/cmmotionmanager
  • CLLocationManager β€” https://developer.apple.com/documentation/corelocation/cllocationmanager
  • CLLocation.speed and speedAccuracy β€” 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
  • HKWorkoutSession β€” HealthKit workout tracking integrated with activity detection
  • CMFallDetectionManager β€” 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+