feat: extend equipment and workout models with service tracking
This commit is contained in:
251
internal/activity/gpx_parser.go
Normal file
251
internal/activity/gpx_parser.go
Normal file
@@ -0,0 +1,251 @@
|
||||
package activity
|
||||
|
||||
import (
|
||||
"encoding/xml"
|
||||
"fmt"
|
||||
"math"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// GPX XML structures
|
||||
type gpxFile struct {
|
||||
Metadata gpxMetadata `xml:"metadata"`
|
||||
Tracks []gpxTrack `xml:"trk"`
|
||||
}
|
||||
|
||||
type gpxMetadata struct {
|
||||
Name string `xml:"name"`
|
||||
Time string `xml:"time"`
|
||||
}
|
||||
|
||||
type gpxTrack struct {
|
||||
Name string `xml:"name"`
|
||||
Type string `xml:"type"`
|
||||
Segments []gpxTrackSeg `xml:"trkseg"`
|
||||
}
|
||||
|
||||
type gpxTrackSeg struct {
|
||||
Points []gpxTrackPoint `xml:"trkpt"`
|
||||
}
|
||||
|
||||
type gpxTrackPoint struct {
|
||||
Lat float64 `xml:"lat,attr"`
|
||||
Lon float64 `xml:"lon,attr"`
|
||||
Elevation float64 `xml:"ele"`
|
||||
Time string `xml:"time"`
|
||||
Extensions gpxTPExtensions `xml:"extensions"`
|
||||
}
|
||||
|
||||
// gpxTPExtensions captures the raw inner XML of extensions for flexible parsing.
|
||||
type gpxTPExtensions struct {
|
||||
InnerXML string `xml:",innerxml"`
|
||||
}
|
||||
|
||||
// ParseGPX parses a GPX activity file, computing metrics from trackpoints.
|
||||
func ParseGPX(data []byte) (*ParsedActivity, error) {
|
||||
var gpx gpxFile
|
||||
if err := xml.Unmarshal(data, &gpx); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse GPX file: %w", err)
|
||||
}
|
||||
|
||||
if len(gpx.Tracks) == 0 {
|
||||
return nil, fmt.Errorf("no tracks found in GPX file")
|
||||
}
|
||||
|
||||
result := &ParsedActivity{}
|
||||
|
||||
// Set title from track or metadata
|
||||
if gpx.Tracks[0].Name != "" {
|
||||
result.Title = gpx.Tracks[0].Name
|
||||
} else if gpx.Metadata.Name != "" {
|
||||
result.Title = gpx.Metadata.Name
|
||||
}
|
||||
|
||||
// Collect all points across tracks and segments
|
||||
var allPoints []gpxTrackPoint
|
||||
for _, trk := range gpx.Tracks {
|
||||
for _, seg := range trk.Segments {
|
||||
allPoints = append(allPoints, seg.Points...)
|
||||
}
|
||||
}
|
||||
|
||||
if len(allPoints) == 0 {
|
||||
return nil, fmt.Errorf("no trackpoints found in GPX file")
|
||||
}
|
||||
|
||||
// Calculate distance using Haversine formula
|
||||
var totalDistance float64
|
||||
for i := 1; i < len(allPoints); i++ {
|
||||
d := haversine(allPoints[i-1].Lat, allPoints[i-1].Lon, allPoints[i].Lat, allPoints[i].Lon)
|
||||
totalDistance += d
|
||||
}
|
||||
result.Distance = totalDistance / 1000.0 // meters to km
|
||||
|
||||
// Calculate duration from timestamps
|
||||
var firstTime, lastTime time.Time
|
||||
for _, pt := range allPoints {
|
||||
if pt.Time == "" {
|
||||
continue
|
||||
}
|
||||
t, err := time.Parse(time.RFC3339, pt.Time)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if firstTime.IsZero() {
|
||||
firstTime = t
|
||||
}
|
||||
lastTime = t
|
||||
}
|
||||
if !firstTime.IsZero() && !lastTime.IsZero() {
|
||||
result.Duration = int(lastTime.Sub(firstTime).Seconds())
|
||||
result.StartTime = firstTime
|
||||
}
|
||||
|
||||
// Calculate elevation gain (sum of positive altitude deltas)
|
||||
var elevGain float64
|
||||
var prevEle float64
|
||||
firstEle := true
|
||||
for _, pt := range allPoints {
|
||||
if pt.Elevation == 0 {
|
||||
continue
|
||||
}
|
||||
if firstEle {
|
||||
prevEle = pt.Elevation
|
||||
firstEle = false
|
||||
continue
|
||||
}
|
||||
delta := pt.Elevation - prevEle
|
||||
if delta > 0 {
|
||||
elevGain += delta
|
||||
}
|
||||
prevEle = pt.Elevation
|
||||
}
|
||||
result.ElevGain = int(math.Round(elevGain))
|
||||
|
||||
// Extract HR, power, cadence from extensions
|
||||
var hrSum, powerSum, cadSum int
|
||||
var hrCount, powerCount, cadCount int
|
||||
var maxHR, maxPower int
|
||||
|
||||
for _, pt := range allPoints {
|
||||
hr, power, cad := parseGPXExtensions(pt.Extensions.InnerXML)
|
||||
|
||||
if hr > 0 {
|
||||
hrSum += hr
|
||||
hrCount++
|
||||
if hr > maxHR {
|
||||
maxHR = hr
|
||||
}
|
||||
}
|
||||
if power > 0 {
|
||||
powerSum += power
|
||||
powerCount++
|
||||
if power > maxPower {
|
||||
maxPower = power
|
||||
}
|
||||
}
|
||||
if cad > 0 {
|
||||
cadSum += cad
|
||||
cadCount++
|
||||
}
|
||||
}
|
||||
|
||||
if hrCount > 0 {
|
||||
result.AvgHR = hrSum / hrCount
|
||||
}
|
||||
result.MaxHR = maxHR
|
||||
|
||||
if powerCount > 0 {
|
||||
result.AvgPower = powerSum / powerCount
|
||||
}
|
||||
result.MaxPower = maxPower
|
||||
|
||||
if cadCount > 0 {
|
||||
result.AvgCadence = cadSum / cadCount
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// parseGPXExtensions extracts HR, power, and cadence from extension XML.
|
||||
// Handles common namespaces: Garmin TrackPointExtension, ClueTrust, generic.
|
||||
func parseGPXExtensions(innerXML string) (hr, power, cadence int) {
|
||||
if innerXML == "" {
|
||||
return
|
||||
}
|
||||
|
||||
// Parse the inner XML as a generic tree
|
||||
type anyElement struct {
|
||||
XMLName xml.Name
|
||||
Value string `xml:",chardata"`
|
||||
Children []anyElement `xml:",any"`
|
||||
}
|
||||
|
||||
var elements []anyElement
|
||||
wrapped := "<ext>" + innerXML + "</ext>"
|
||||
var wrapper struct {
|
||||
Children []anyElement `xml:",any"`
|
||||
}
|
||||
if err := xml.Unmarshal([]byte(wrapped), &wrapper); err != nil {
|
||||
return
|
||||
}
|
||||
elements = wrapper.Children
|
||||
|
||||
// Walk the element tree looking for known field names
|
||||
var walk func(elems []anyElement)
|
||||
walk = func(elems []anyElement) {
|
||||
for _, el := range elems {
|
||||
local := strings.ToLower(el.XMLName.Local)
|
||||
switch local {
|
||||
case "hr":
|
||||
if v, err := strconv.Atoi(strings.TrimSpace(el.Value)); err == nil && v > 0 {
|
||||
hr = v
|
||||
}
|
||||
// HR might be nested: <hr><Value>145</Value></hr>
|
||||
for _, child := range el.Children {
|
||||
if strings.ToLower(child.XMLName.Local) == "value" {
|
||||
if v, err := strconv.Atoi(strings.TrimSpace(child.Value)); err == nil && v > 0 {
|
||||
hr = v
|
||||
}
|
||||
}
|
||||
}
|
||||
case "power", "watts":
|
||||
if v, err := strconv.Atoi(strings.TrimSpace(el.Value)); err == nil && v > 0 {
|
||||
power = v
|
||||
}
|
||||
case "cad":
|
||||
if v, err := strconv.Atoi(strings.TrimSpace(el.Value)); err == nil && v > 0 {
|
||||
cadence = v
|
||||
}
|
||||
}
|
||||
// Recurse into children (e.g., TrackPointExtension wrapper)
|
||||
if len(el.Children) > 0 {
|
||||
walk(el.Children)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
walk(elements)
|
||||
return
|
||||
}
|
||||
|
||||
// haversine calculates the distance in meters between two lat/lon points.
|
||||
func haversine(lat1, lon1, lat2, lon2 float64) float64 {
|
||||
const earthRadius = 6371000.0 // meters
|
||||
|
||||
dLat := degToRad(lat2 - lat1)
|
||||
dLon := degToRad(lon2 - lon1)
|
||||
|
||||
a := math.Sin(dLat/2)*math.Sin(dLat/2) +
|
||||
math.Cos(degToRad(lat1))*math.Cos(degToRad(lat2))*
|
||||
math.Sin(dLon/2)*math.Sin(dLon/2)
|
||||
c := 2 * math.Atan2(math.Sqrt(a), math.Sqrt(1-a))
|
||||
|
||||
return earthRadius * c
|
||||
}
|
||||
|
||||
func degToRad(deg float64) float64 {
|
||||
return deg * math.Pi / 180.0
|
||||
}
|
||||
Reference in New Issue
Block a user