182 lines
4.4 KiB
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))
|
|
}
|