Getting High-Frequency Tennis Motion Data from Apple Watch [Part 1]

Written by pavelshadrin | Published 2023/10/09
Tech Story Tags: swift-programming | data-collection | ios-app-development | ios | watch-os | healthkit | apple-watch | ios-development

TLDRvia the TL;DR App

Part 1: Sample App for Collecting High-Frequency Sensor Data

I have played tennis for 25 years, and I always try to improve my game – whether by watching some coaching videos or trying new apps like Swing Vision. At the latest WWDC, I noticed a What's New in Core Motion session about introducing higher-frequency sensor data. The presenter was showing how such data can be used to analyze a golf swing. I thought that tennis could also be a perfect use case for leveraging the new sensors, so I set out to build an app that would collect raw motion data and share it as a file for further analysis.

In this part 1, I will show how to build such an app and share sample code. In the following parts, I will present data from a real tennis session and try to turn it into something meaningful.

New in Core Motion

Core Motion has been with us since the early days of iOS SDK, and it has always provided interesting capabilities that led to fun applications – such as the famous iBeer – or games where you could tilt your phone to control objects. On iPhones, Core Motion has also powered utility things like navigation and, eventually, Health and Activity. With the advent of the Apple Watch, motion data became even more useful in various workout apps where developers could use Apple-provided APIs or just raw data from the sensors.

Starting from WatchOS 10.0, there is a new class, CMBatchedSensorManager, that can give 800-Hz accelerometer and 200-Hz gyroscope updates, which are 8x and 2x, respectively, more frequent than in the previous versions.

Sample App

Let's implement a simple Watch app that can collect the data from the new sensors and transmit them to the iPhone so that we can use this data later.

Full code is available on my GitHub: https://github.com/pavelshadrin/tennis-motion

First of all, we need a shared Codable model that would be able to hold the sensor data. Codable will let us encode and pass this data between the devices or store it on disk if needed:

struct AccelerometerSnapshot: Codable {
    let timestamp: TimeInterval
    let accelerationX: Double
    let accelerationY: Double
    let accelerationZ: Double
}

struct GyroscopeSnapshot: Codable {
    let timestamp: TimeInterval
    let rotationX: Double
    let rotationY: Double
    let rotationZ: Double
}

struct TennisMotionData: Codable {
    let accelerometerSnapshots: [AccelerometerSnapshot]
    let gyroscopeSnapshots: [GyroscopeSnapshot]
}

struct TennisDataChunk: Codable {
    let date: Date
    let data: TennisMotionData

    // init from non-codable CM classes
}

On the Watch, apart from the UI to start and stop motion data collection, we need to implement several main things:

1. Set up CMBatchedSensorManager

This is the main driving force of our app.

let sensorManager = CMBatchedSensorManager()

2. Set up HealthKit and workout-related logic

As a bonus, the app will record tennis workouts and save them in the Health app.

let healthStore = HKHealthStore()
var workoutSession: HKWorkoutSession?
var builder: HKLiveWorkoutBuilder?

// ...

// sensor data can be collected only during workout
let configuration = HKWorkoutConfiguration()
configuration.activityType = .tennis
configuration.locationType = .outdoor

do {
    workoutSession = try HKWorkoutSession(healthStore: healthStore, configuration: configuration)
    builder = workoutSession?.associatedWorkoutBuilder()
} catch {
    return
}

builder?.dataSource = HKLiveWorkoutDataSource(
    healthStore: healthStore,
    workoutConfiguration: configuration
)

3. Set up WatchConnectivity session to pass the recorded data to iPhone

let session = WCSession.default

if WCSession.isSupported() {
    session.delegate = self
    session.activate()
}

4. Connect all those pieces together

When the user starts data collection, we need to start the activity and begin data collection:

let startDate = Date()
workoutSession?.startActivity(with: startDate)
builder?.beginCollection(withStart: startDate) { (success, error) in
    if success {
        self.state = .active
    }

    Task {
        do {
            for try await data in CMBatchedSensorManager().accelerometerUpdates() {
                let dataChunk = TennisDataChunk(date: Date(), accelerometerData: data, gyroscopeData: [])
                sendToiPhone(dataChunk: dataChunk)
            }
        } catch let error as NSError {
            print("\(error)")
        }
    }

    Task {
        do {
            for try await data in CMBatchedSensorManager().deviceMotionUpdates() {
                let dataChunk = TennisDataChunk(date: Date(), accelerometerData: [], gyroscopeData: data)
                sendToiPhone(dataChunk: dataChunk)
            }
        } catch let error as NSError {
            print("\(error)")
        }
    }  
}

With this code above, every chunk of fresh data from the sensors will be immediately sent to the iPhone. If we don't send it right away, the data will become too large to fit into a message payload that can be sent quickly with session.sendMessage. Otherwise, we would have to pass files. The simplest and quickest way is still to send a message with a Dictionary:

private func sendToiPhone(dataChunk: TennisDataChunk) {
    let dict: [String : Any] = ["data": dataChunk.encodeIt()]
    session.sendMessage(dict, replyHandler: { reply in
        print("Got reply from iPhone")
    }, errorHandler: { error in
        print("Failed to send data to iPhone: \(error)")
    })
}

Then, on the receiving end, the main things to take care of are:

1. Receive and decode the motion data chunks to display them in a table view

extension ViewController: WCSessionDelegate {
    // ...

    func session(_ session: WCSession, didReceiveMessage message: [String : Any], replyHandler: @escaping ([String : Any]) -> Void) {
        guard let data: Data = message["data"] as? Data else { return }
        let chunk = TennisDataChunk.decodeIt(data)
        DispatchQueue.main.async {
            self.tennisDataChunks.append(chunk)
        }
    }
}

2. Be able to export those chunks as a file – for example, a .csv and then share it using UIActivityViewController to send it via AirDrop or save it to iCloud drive:

private func exportAccelerometerData() {
    var result = tennisDataChunks.reduce("") { partialResult, chunk in
        if !chunk.data.accelerometerSnapshots.isEmpty {
            return partialResult + "\n\(chunk.createAcceletometerDataCSV())"
        }
    }
    shareStringAsFile(string: result, filename: "tennis-acceleration-\(Date()).csv")
}

// same for gyroscope ^

// creating file and sharing it with share sheer
private func shareStringAsFile(string: String, filename: String) {
    if string.isEmpty {
        return
    }

    do {
        let filename = "\(self.getDocumentsDirectory())/\(filename)"
        let fileURL = URL(fileURLWithPath: filename)
        try string.write(to: fileURL, atomically: true, encoding: .utf8)

        let vc = UIActivityViewController(activityItems: [fileURL], applicationActivities: [])

        self.present(vc, animated: true)
    } catch {
        print("cannot write file")
    }
}

private func getDocumentsDirectory() -> String {
    let paths = NSSearchPathForDirectoriesInDomains(.documentDirectory, .userDomainMask, true)
    let documentsDirectory = paths[0]
    return documentsDirectory
}

Now, we can run both apps simultaneously and see the data popping up on the iPhone. This is what the final app looks like:

Conclusion

We've implemented two apps, one of which collects the motion data, encodes it, and sends the payload to the companion app. On the other side, we decode the payload, render it in the table view, and export it by writing the data into a .csv file and launching the system's share sheet. I hope this can serve as a good example of how to use the new APIs and leverage the frameworks that are commonly used in various workout and motion Apple Watch apps.

What's Next

In the next article, I will show the motion data collected from my Apple Watch during a tennis session warm-up.


Written by pavelshadrin | iOS engineer @ Instagram
Published by HackerNoon on 2023/10/09