Begin statistics creation

This commit is contained in:
Christoph Hagen
2025-08-31 16:27:32 +02:00
parent f972a2c020
commit 96bd07bdb7
33 changed files with 1406 additions and 187 deletions

View 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
}
}
}