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

252 lines
5.7 KiB
Go

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
}