import Foundation import CoreLocation struct StatisticsFileGenerationJob { let mapCoordinates: [CGPoint] let workout: WorkoutData let outputPath: URL let numberOfSamples: Int /// The minimum pace to allow, in min/km let minimumPace: Double init(mapCoordinates: [CGPoint], workout: WorkoutData, path: URL, numberOfSamples: Int, minimumPace: Double = 60) { self.mapCoordinates = mapCoordinates self.workout = workout self.outputPath = path self.numberOfSamples = numberOfSamples self.minimumPace = minimumPace } var locations: [CLLocation] { workout.locations } var heartRateSamples: [WorkoutData.Sample] { workout.heartRates } var energy: [WorkoutData.Sample] { workout.energy } var start: Date? { workout.start } var end: Date? { workout.end } var duration: TimeInterval { guard let start, let end else { return 0 } return end.timeIntervalSince(start) } /** Points representing the distance (`y`, in meters) against duration (`x`, in seconds) */ func distances() -> [Point] { guard var current = locations.first else { return [] } var distanceSoFar = 0.0 let start = current.timestamp return locations.dropFirst().map { next in let time = next.duration(since: start) distanceSoFar += next.distance(from: current) current = next return Point(x: time, y: distanceSoFar) } } func run() -> Bool { let startLocation = locations.first! let start = startLocation.timestamp let end = locations.last!.timestamp let totalDuration = end.timeIntervalSince(start) let totalDistance = locations.totalDistance // in meter let minimumPace = minimumPace * 60 // Convert to s/km let rawDistances = distances() // m /// In meter let distances: [Point] = rawDistances.resample(numberOfSamples: numberOfSamples, mode: .instantaneous) let times = distances.map { $0.x } let xValues: [Double] = locations.enumerated().map { (index, location) in Point(x: location.duration(since: start), y: mapCoordinates[index].x) }.resample(numberOfSamples: numberOfSamples, mode: .instantaneous) let yValues: [Double] = locations.enumerated().map { (index, location) in Point(x: location.duration(since: start), y: mapCoordinates[index].y) }.resample(numberOfSamples: numberOfSamples, mode: .instantaneous) let elevations: [Double] = locations.resample( numberOfSamples: numberOfSamples, minX: 0, maxX: totalDuration, x: { $0.duration(since: start) }, y: { $0.altitude }, mode: .instantaneous) let speeds: [Double] = rawDistances.resample( numberOfSamples: numberOfSamples, minX: 0, maxX: totalDuration, mode: .cumulative) .map { $0 * 3.6 } // Convert from m/s to km/h .medianFiltered(windowSize: 15) // Speed is km/h, pace is s/km let paces: [Double] = speeds.map { guard $0 > 0 else { return minimumPace } let converted = 3600 / $0 // s/km return min(minimumPace, converted) } let heartRates: [Double] = heartRateSamples.resample( numberOfSamples: numberOfSamples, minX: 0, maxX: totalDuration, x: { $0.time.timeIntervalSince(start) }, y: { $0.value }, mode: .instantaneous) .map { $0 * 60 } // from hz to bpm .medianFiltered(windowSize: 15) let energies: [Double] = energy.resample( numberOfSamples: numberOfSamples, minX: 0, maxX: totalDuration, x: { $0.time.timeIntervalSince(start) }, y: { $0.value }, mode: .instantaneous) .map { $0 * 60 } // from kcal/s to kcal/min .medianFiltered(windowSize: 10) let series = RouteSeries( elevation: elevations.fittingAxisLimits(minLimit: 0), speed: speeds.fittingAxisLimits(minLimit: 0), pace: paces.fittingTimeAxisLimits(maxLimit: minimumPace), hr: heartRates.fittingAxisLimits(minLimit: 0), energy: energies.fittingAxisLimits(minLimit: 0)) let ranges = DataRanges( duration: .init(min: 0, max: totalDuration), time: .init(min: start.timeIntervalSince1970, max: end.timeIntervalSince1970), distance: .init(min: 0, max: totalDistance / 1000)) // km let samples: [RouteSample] = (0.. RouteSample in RouteSample( x: xValues[index], y: yValues[index], time: times[index] / totalDuration, distance: distances[index].y / totalDistance, elevation: series.elevation.scale(elevations[index]), speed: series.speed.scale(speeds[index]), pace: series.pace.scale(paces[index]), hr: series.hr.scale(heartRates[index]), energy: series.energy!.scale(energies[index])) } let result = RouteData( series: series, ranges: ranges, samples: samples) let data = result.encoded() do { try data.write(to: outputPath) return true } catch { print("Failed to write file \(outputPath.path()): \(error.localizedDescription)") return false } } }