Files
rideaware-api/internal/activity/tcx_parser.go

182 lines
4.4 KiB
Go

package activity
import (
"encoding/xml"
"fmt"
"math"
"time"
)
// TCX XML structures
type tcxDatabase struct {
Activities struct {
Activity []tcxActivity `xml:"Activity"`
} `xml:"Activities"`
}
type tcxActivity struct {
Sport string `xml:"Sport,attr"`
ID string `xml:"Id"`
Laps []tcxLap `xml:"Lap"`
}
type tcxLap struct {
StartTime string `xml:"StartTime,attr"`
TotalTimeSeconds float64 `xml:"TotalTimeSeconds"`
DistanceMeters float64 `xml:"DistanceMeters"`
Calories int `xml:"Calories"`
AvgHR tcxHeartRate `xml:"AverageHeartRateBpm"`
MaxHR tcxHeartRate `xml:"MaximumHeartRateBpm"`
Cadence int `xml:"Cadence"`
Extensions tcxExtensions `xml:"Extensions"`
Track tcxTrack `xml:"Track"`
}
type tcxHeartRate struct {
Value int `xml:"Value"`
}
type tcxExtensions struct {
LX tcxLapExtension `xml:"LX"`
}
type tcxLapExtension struct {
AvgWatts int `xml:"AvgWatts"`
MaxWatts int `xml:"MaxWatts"`
}
type tcxTrack struct {
Trackpoints []tcxTrackpoint `xml:"Trackpoint"`
}
type tcxTrackpoint struct {
Time string `xml:"Time"`
AltitudeMeters float64 `xml:"AltitudeMeters"`
DistanceMeters float64 `xml:"DistanceMeters"`
HeartRateBpm tcxHeartRate `xml:"HeartRateBpm"`
Cadence int `xml:"Cadence"`
HasAltitude bool
}
// ParseTCX parses a TCX activity file and extracts aggregated metrics from laps.
func ParseTCX(data []byte) (*ParsedActivity, error) {
var db tcxDatabase
if err := xml.Unmarshal(data, &db); err != nil {
return nil, fmt.Errorf("failed to parse TCX file: %w", err)
}
if len(db.Activities.Activity) == 0 {
return nil, fmt.Errorf("no activities found in TCX file")
}
act := db.Activities.Activity[0]
if len(act.Laps) == 0 {
return nil, fmt.Errorf("no laps found in TCX activity")
}
result := &ParsedActivity{}
var totalDuration float64
var totalDistance float64
var totalCalories int
var maxHR int
var maxPower int
// Weighted sums for averages
var hrDurationSum float64
var powerDurationSum float64
var cadDurationSum float64
var hrDuration float64
var powerDuration float64
var cadDuration float64
for _, lap := range act.Laps {
totalDuration += lap.TotalTimeSeconds
totalDistance += lap.DistanceMeters
totalCalories += lap.Calories
if lap.AvgHR.Value > 0 {
hrDurationSum += float64(lap.AvgHR.Value) * lap.TotalTimeSeconds
hrDuration += lap.TotalTimeSeconds
}
if lap.MaxHR.Value > maxHR {
maxHR = lap.MaxHR.Value
}
if lap.Extensions.LX.AvgWatts > 0 {
powerDurationSum += float64(lap.Extensions.LX.AvgWatts) * lap.TotalTimeSeconds
powerDuration += lap.TotalTimeSeconds
}
if lap.Extensions.LX.MaxWatts > maxPower {
maxPower = lap.Extensions.LX.MaxWatts
}
if lap.Cadence > 0 {
cadDurationSum += float64(lap.Cadence) * lap.TotalTimeSeconds
cadDuration += lap.TotalTimeSeconds
}
}
result.Duration = int(math.Round(totalDuration))
result.Distance = totalDistance / 1000.0 // meters to km
result.CaloriesBurned = totalCalories
result.MaxHR = maxHR
result.MaxPower = maxPower
if hrDuration > 0 {
result.AvgHR = int(math.Round(hrDurationSum / hrDuration))
}
if powerDuration > 0 {
result.AvgPower = int(math.Round(powerDurationSum / powerDuration))
}
if cadDuration > 0 {
result.AvgCadence = int(math.Round(cadDurationSum / cadDuration))
}
// Calculate elevation gain from trackpoints
result.ElevGain = calculateTCXElevGain(act.Laps)
// Parse start time from activity ID or first lap
if act.ID != "" {
if t, err := time.Parse(time.RFC3339, act.ID); err == nil {
result.StartTime = t
}
}
if result.StartTime.IsZero() && len(act.Laps) > 0 && act.Laps[0].StartTime != "" {
if t, err := time.Parse(time.RFC3339, act.Laps[0].StartTime); err == nil {
result.StartTime = t
}
}
return result, nil
}
// calculateTCXElevGain computes total ascent from trackpoint altitude data.
func calculateTCXElevGain(laps []tcxLap) int {
var elevGain float64
var prevAlt float64
first := true
for _, lap := range laps {
for _, tp := range lap.Track.Trackpoints {
if tp.AltitudeMeters == 0 {
continue
}
if first {
prevAlt = tp.AltitudeMeters
first = false
continue
}
delta := tp.AltitudeMeters - prevAlt
if delta > 0 {
elevGain += delta
}
prevAlt = tp.AltitudeMeters
}
}
return int(math.Round(elevGain))
}