252 lines
5.7 KiB
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
|
|
}
|