feat: extend equipment and workout models with service tracking
This commit is contained in:
181
internal/activity/tcx_parser.go
Normal file
181
internal/activity/tcx_parser.go
Normal file
@@ -0,0 +1,181 @@
|
||||
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))
|
||||
}
|
||||
Reference in New Issue
Block a user