Begin statistics creation
This commit is contained in:
174
CHDataManagement/Generator/StatisticsFileGenerator.swift
Normal file
174
CHDataManagement/Generator/StatisticsFileGenerator.swift
Normal file
@@ -0,0 +1,174 @@
|
||||
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..<numberOfSamples).map { index -> 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
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user