summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--ride/parser_test.go42
-rw-r--r--ride/ride.go127
-rw-r--r--strava/sync.go85
-rw-r--r--strava/sync_test.go37
4 files changed, 267 insertions, 24 deletions
diff --git a/ride/parser_test.go b/ride/parser_test.go
index 96c837e..c2d6719 100644
--- a/ride/parser_test.go
+++ b/ride/parser_test.go
@@ -11,6 +11,7 @@ import (
const parserTestGPXPrefix = `<?xml version="1.0"?><gpx xmlns="http://www.topografix.com/GPX/1/1" version="1.1"><trk><trkseg>`
const parserTestGPXSuffix = `</trkseg></trk></gpx>`
+const parserTestGPXMetricsPrefix = `<?xml version="1.0"?><gpx xmlns="http://www.topografix.com/GPX/1/1" xmlns:gpxtpx="http://www.garmin.com/xmlschemas/TrackPointExtension/v1" version="1.1"><trk><trkseg>`
func TestGPXRideParserSkipsStationaryPoints(t *testing.T) {
data := parserTestGPXPrefix +
@@ -44,3 +45,44 @@ func TestGPXRideParserRejectsEmptyTrack(t *testing.T) {
require.Error(t, err)
assert.Equal(t, "ride has no track points", err.Error())
}
+
+func TestGPXRideParserReadsGarminMetrics(t *testing.T) {
+ data := parserTestGPXMetricsPrefix +
+ `<trkpt lat="43.0" lon="5.0"><ele>100</ele><extensions><gpxtpx:TrackPointExtension><gpxtpx:hr>90</gpxtpx:hr><gpxtpx:cad>70</gpxtpx:cad><gpxtpx:watts>200</gpxtpx:watts></gpxtpx:TrackPointExtension></extensions></trkpt>` +
+ `<trkpt lat="43.0" lon="5.0"><ele>101</ele><extensions><gpxtpx:TrackPointExtension><gpxtpx:hr>91</gpxtpx:hr><gpxtpx:cad>71</gpxtpx:cad><gpxtpx:watts>201</gpxtpx:watts></gpxtpx:TrackPointExtension></extensions></trkpt>` +
+ `<trkpt lat="43.001" lon="5.001"><ele>102</ele><extensions><gpxtpx:TrackPointExtension><gpxtpx:hr>92</gpxtpx:hr><gpxtpx:cad>72</gpxtpx:cad><gpxtpx:watts>202</gpxtpx:watts></gpxtpx:TrackPointExtension></extensions></trkpt>` +
+ parserTestGPXSuffix
+
+ parsed, err := (ride.GPXRideParser{}).Parse(strings.NewReader(data))
+ require.NoError(t, err)
+ assert.Equal(t, 2, parsed.Len())
+
+ heartRate, found := parsed.HeartRateBpm(1)
+ require.True(t, found)
+ assert.Equal(t, 92.0, heartRate)
+ cadence, found := parsed.CadenceRpm(1)
+ require.True(t, found)
+ assert.Equal(t, 72.0, cadence)
+ power, found := parsed.PowerW(1)
+ require.True(t, found)
+ assert.Equal(t, 202.0, power)
+}
+
+func TestGPXRideParserDropsIncompleteMetricColumn(t *testing.T) {
+ data := parserTestGPXMetricsPrefix +
+ `<trkpt lat="43.0" lon="5.0"><ele>100</ele><extensions><gpxtpx:TrackPointExtension><gpxtpx:hr>90</gpxtpx:hr><gpxtpx:cad>70</gpxtpx:cad><gpxtpx:watts>200</gpxtpx:watts></gpxtpx:TrackPointExtension></extensions></trkpt>` +
+ `<trkpt lat="43.001" lon="5.001"><ele>102</ele><extensions><gpxtpx:TrackPointExtension><gpxtpx:hr>92</gpxtpx:hr><gpxtpx:watts>0</gpxtpx:watts></gpxtpx:TrackPointExtension></extensions></trkpt>` +
+ parserTestGPXSuffix
+
+ parsed, err := (ride.GPXRideParser{}).Parse(strings.NewReader(data))
+ require.NoError(t, err)
+
+ _, found := parsed.CadenceRpm(0)
+ assert.False(t, found)
+ heartRate, found := parsed.HeartRateBpm(1)
+ require.True(t, found)
+ assert.Equal(t, 92.0, heartRate)
+ power, found := parsed.PowerW(1)
+ require.True(t, found)
+ assert.Equal(t, 0.0, power)
+}
diff --git a/ride/ride.go b/ride/ride.go
index 311e933..ef91272 100644
--- a/ride/ride.go
+++ b/ride/ride.go
@@ -2,9 +2,12 @@ package ride
import (
"errors"
+ "fmt"
"io"
"math"
"os"
+ "strconv"
+ "strings"
"time"
"github.com/jftuga/geodist"
@@ -16,11 +19,16 @@ import (
"gonum.org/v1/plot/vg"
)
+const garminTrackPointExtensionNamespace = "http://www.garmin.com/xmlschemas/TrackPointExtension/v1"
+
type Ride struct {
distances []float64
elevations []float64
coords []geodist.Coord
timestamps []time.Time
+ heartRates []float64
+ cadences []float64
+ powers []float64
}
func (r *Ride) check() {
@@ -28,6 +36,11 @@ func (r *Ride) check() {
kcore.Assert(len(r.distances) == len(r.elevations), "ride columns have different lengths")
kcore.Assert(len(r.distances) == len(r.coords), "ride columns have different lengths")
kcore.Assert(len(r.distances) == len(r.timestamps), "ride columns have different lengths")
+ for _, column := range [][]float64{r.heartRates, r.cadences, r.powers} {
+ if len(column) > 0 {
+ kcore.Assert(len(r.distances) == len(column), "ride columns have different lengths")
+ }
+ }
}
func (r Ride) Len() int {
@@ -42,6 +55,25 @@ func (r Ride) Coord(i int) geodist.Coord { return r.coords[i] }
func (r Ride) Timestamp(i int) time.Time { return r.timestamps[i] }
+func (r Ride) HeartRateBpm(i int) (float64, bool) {
+ return optionalMetric(r.heartRates, i)
+}
+
+func (r Ride) CadenceRpm(i int) (float64, bool) {
+ return optionalMetric(r.cadences, i)
+}
+
+func (r Ride) PowerW(i int) (float64, bool) {
+ return optionalMetric(r.powers, i)
+}
+
+func optionalMetric(column []float64, i int) (float64, bool) {
+ if len(column) == 0 {
+ return 0, false
+ }
+ return column[i], true
+}
+
type RideParser interface {
Parse(reader io.Reader) (Ride, error)
}
@@ -73,6 +105,12 @@ func (p GPXRideParser) Parse(reader io.Reader) (Ride, error) {
elevations := make([]float64, 0, len(segment.Points))
coords := make([]geodist.Coord, 0, len(segment.Points))
timestamps := make([]time.Time, 0, len(segment.Points))
+ heartRates := make([]float64, 0, len(segment.Points))
+ cadences := make([]float64, 0, len(segment.Points))
+ powers := make([]float64, 0, len(segment.Points))
+ heartRateComplete := true
+ cadenceComplete := true
+ powerComplete := true
distance := 0.0
previous := segment.Points[0]
if previous.Elevation.Null() {
@@ -82,6 +120,13 @@ func (p GPXRideParser) Parse(reader io.Reader) (Ride, error) {
elevations = append(elevations, previous.Elevation.Value())
coords = append(coords, geodist.Coord{Lat: previous.Latitude, Lon: previous.Longitude})
timestamps = append(timestamps, previous.Timestamp)
+ sample, err := metricSampleForPoint(previous)
+ if err != nil {
+ return Ride{}, err
+ }
+ appendMetric(&heartRates, &heartRateComplete, sample.heartRate)
+ appendMetric(&cadences, &cadenceComplete, sample.cadence)
+ appendMetric(&powers, &powerComplete, sample.power)
for i := 1; i < len(segment.Points); i++ {
p := segment.Points[i]
distance += p.Distance2D(&previous)
@@ -96,21 +141,95 @@ func (p GPXRideParser) Parse(reader io.Reader) (Ride, error) {
elevations = append(elevations, p.Elevation.Value())
coords = append(coords, geodist.Coord{Lat: p.Latitude, Lon: p.Longitude})
timestamps = append(timestamps, p.Timestamp)
+ sample, err = metricSampleForPoint(p)
+ if err != nil {
+ return Ride{}, err
+ }
+ appendMetric(&heartRates, &heartRateComplete, sample.heartRate)
+ appendMetric(&cadences, &cadenceComplete, sample.cadence)
+ appendMetric(&powers, &powerComplete, sample.power)
}
if len(distances) < 2 {
return Ride{}, errors.New("zero distance")
}
- ride := Ride{distances: distances, elevations: elevations, coords: coords, timestamps: timestamps}
- ride.check()
- return ride, nil
+ var heartRatesColumn, cadencesColumn, powersColumn []float64
+ if heartRateComplete {
+ heartRatesColumn = heartRates
+ }
+ if cadenceComplete {
+ cadencesColumn = cadences
+ }
+ if powerComplete {
+ powersColumn = powers
+ }
+ return fromColumns(distances, elevations, coords, timestamps, heartRatesColumn, cadencesColumn, powersColumn), nil
}
func FromColumns(distances []float64, elevations []float64, coords []geodist.Coord, timestamps []time.Time) Ride {
- ride := Ride{distances: distances, elevations: elevations, coords: coords, timestamps: timestamps}
+ return fromColumns(distances, elevations, coords, timestamps, nil, nil, nil)
+}
+
+func fromColumns(distances []float64, elevations []float64, coords []geodist.Coord, timestamps []time.Time, heartRates, cadences, powers []float64) Ride {
+ ride := Ride{
+ distances: distances,
+ elevations: elevations,
+ coords: coords,
+ timestamps: timestamps,
+ heartRates: heartRates,
+ cadences: cadences,
+ powers: powers,
+ }
ride.check()
return ride
}
+type metricSample struct {
+ heartRate *float64
+ cadence *float64
+ power *float64
+}
+
+func metricSampleForPoint(point gpx.GPXPoint) (metricSample, error) {
+ heartRate, err := metricValue(point, "hr")
+ if err != nil {
+ return metricSample{}, err
+ }
+ cadence, err := metricValue(point, "cad")
+ if err != nil {
+ return metricSample{}, err
+ }
+ power, err := metricValue(point, "watts")
+ if err != nil {
+ return metricSample{}, err
+ }
+ return metricSample{heartRate: heartRate, cadence: cadence, power: power}, nil
+}
+
+func appendMetric(column *[]float64, complete *bool, value *float64) {
+ if value == nil {
+ *complete = false
+ *column = append(*column, 0)
+ return
+ }
+ *column = append(*column, *value)
+}
+
+func metricValue(point gpx.GPXPoint, name string) (*float64, error) {
+ trackPointExtension, found := point.Extensions.GetNode(gpx.NamespaceURL(garminTrackPointExtensionNamespace), "TrackPointExtension")
+ if !found {
+ return nil, nil
+ }
+ node, found := trackPointExtension.GetNode(name)
+ if !found {
+ return nil, nil
+ }
+ value, err := strconv.ParseFloat(strings.TrimSpace(node.Data), 64)
+ if err != nil {
+ return nil, fmt.Errorf("invalid Garmin %s value %q: %w", name, node.Data, err)
+ }
+ return &value, nil
+}
+
func (r *Ride) ScoreFromKm(start, end float64) float64 {
i := 0
j := 0
diff --git a/strava/sync.go b/strava/sync.go
index e798410..762b41a 100644
--- a/strava/sync.go
+++ b/strava/sync.go
@@ -10,6 +10,8 @@ import (
"github.com/tkrajina/gpxgo/gpx"
)
+const garminTrackPointExtensionNamespace = "http://www.garmin.com/xmlschemas/TrackPointExtension/v1"
+
type Activity struct {
ID int64 `json:"id"`
Name string `json:"name"`
@@ -68,19 +70,32 @@ func (c *Client) Get(id int64) (Activity, []byte, error) {
if err := c.GetJSON(fmt.Sprintf("/activities/%d", id), &activity); err != nil {
return Activity{}, nil, kcore.Wrap(err, "failed to get Strava activity")
}
- latlng, altitude, seconds, err := c.activityStreams(id)
+ streams, err := c.activityStreams(id)
if err != nil {
return Activity{}, nil, err
}
- gpxData, err := activityGPX(activity, latlng, altitude, seconds)
+ gpxData, err := activityGPX(activity, streams)
if err != nil {
return Activity{}, nil, err
}
return activity, gpxData, nil
}
-func (c *Client) activityStreams(id int64) (latlng [][2]float64, altitude []float64, seconds []float64, err error) {
- path := fmt.Sprintf("/activities/%d/streams?keys=latlng,altitude,time&key_by_type=true", id)
+type activityStreams struct {
+ LatLng [][2]float64
+ Altitude []float64
+ Seconds []float64
+ HeartRate []*float64
+ Cadence []*float64
+ Power []*float64
+}
+
+type nullableFloatStream struct {
+ Data []*float64 `json:"data"`
+}
+
+func (c *Client) activityStreams(id int64) (activityStreams, error) {
+ path := fmt.Sprintf("/activities/%d/streams?keys=latlng,altitude,time,heartrate,cadence,watts&key_by_type=true", id)
var streams struct {
LatLng *struct {
Data [][2]float64 `json:"data"`
@@ -91,35 +106,51 @@ func (c *Client) activityStreams(id int64) (latlng [][2]float64, altitude []floa
Time *struct {
Data []float64 `json:"data"`
} `json:"time"`
+ HeartRate *nullableFloatStream `json:"heartrate"`
+ Cadence *nullableFloatStream `json:"cadence"`
+ Power *nullableFloatStream `json:"watts"`
}
- err = c.GetJSON(path, &streams)
+ err := c.GetJSON(path, &streams)
if err != nil {
- return nil, nil, nil, kcore.Wrap(err, "failed to fetch Strava streams")
+ return activityStreams{}, kcore.Wrap(err, "failed to fetch Strava streams")
}
+ result := activityStreams{}
if streams.LatLng != nil {
- latlng = streams.LatLng.Data
+ result.LatLng = streams.LatLng.Data
}
if streams.Altitude != nil {
- altitude = streams.Altitude.Data
+ result.Altitude = streams.Altitude.Data
}
if streams.Time != nil {
- seconds = streams.Time.Data
+ result.Seconds = streams.Time.Data
+ }
+ if streams.HeartRate != nil {
+ result.HeartRate = streams.HeartRate.Data
+ }
+ if streams.Cadence != nil {
+ result.Cadence = streams.Cadence.Data
}
- return latlng, altitude, seconds, nil
+ if streams.Power != nil {
+ result.Power = streams.Power.Data
+ }
+ return result, nil
}
-func activityGPX(activity Activity, latlng [][2]float64, altitude, seconds []float64) ([]byte, error) {
- points := make([]gpx.GPXPoint, len(latlng))
- for i := range latlng {
+func activityGPX(activity Activity, streams activityStreams) ([]byte, error) {
+ points := make([]gpx.GPXPoint, len(streams.LatLng))
+ for i := range streams.LatLng {
point := gpx.GPXPoint{}
- point.Latitude = latlng[i][0]
- point.Longitude = latlng[i][1]
- if i < len(altitude) {
- point.Elevation = *gpx.NewNullableFloat64(altitude[i])
+ point.Latitude = streams.LatLng[i][0]
+ point.Longitude = streams.LatLng[i][1]
+ if i < len(streams.Altitude) {
+ point.Elevation = *gpx.NewNullableFloat64(streams.Altitude[i])
}
- if i < len(seconds) {
- point.Timestamp = activity.StartDate.Add(time.Duration(seconds[i]) * time.Second)
+ if i < len(streams.Seconds) {
+ point.Timestamp = activity.StartDate.Add(time.Duration(streams.Seconds[i]) * time.Second)
}
+ addTrackPointMetric(&point, "hr", streamValue(streams.HeartRate, i))
+ addTrackPointMetric(&point, "cad", streamValue(streams.Cadence, i))
+ addTrackPointMetric(&point, "watts", streamValue(streams.Power, i))
points[i] = point
}
doc := gpx.GPX{
@@ -131,9 +162,25 @@ func activityGPX(activity Activity, latlng [][2]float64, altitude, seconds []flo
Segments: []gpx.GPXTrackSegment{{Points: points}},
}},
}
+ doc.RegisterNamespace("gpxtpx", garminTrackPointExtensionNamespace)
xmlData, err := doc.ToXml(gpx.ToXmlParams{})
if err != nil {
return nil, kcore.Wrap(err, "failed to build GPX XML")
}
return xmlData, nil
}
+
+func streamValue(stream []*float64, i int) *float64 {
+ if i >= len(stream) {
+ return nil
+ }
+ return stream[i]
+}
+
+func addTrackPointMetric(point *gpx.GPXPoint, name string, value *float64) {
+ if value == nil {
+ return
+ }
+ extension := point.Extensions.GetOrCreateNode(gpx.NamespaceURL(garminTrackPointExtensionNamespace), "TrackPointExtension")
+ extension.GetOrCreateNode(name).Data = strconv.FormatFloat(*value, 'f', -1, 64)
+}
diff --git a/strava/sync_test.go b/strava/sync_test.go
index 04f3c64..f810206 100644
--- a/strava/sync_test.go
+++ b/strava/sync_test.go
@@ -1,12 +1,14 @@
package strava
import (
+ "bytes"
"fmt"
"net/http"
"net/http/httptest"
"testing"
"time"
+ "github.com/martinlehoux/biking_home/ride"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/tkrajina/gpxgo/gpx"
@@ -60,7 +62,8 @@ func TestGet(t *testing.T) {
_, err := w.Write([]byte(`{"id":42,"name":"Ride A","type":"Ride","sport_type":"Ride","start_date":"2026-01-01T10:00:00Z","distance":42195,"moving_time":7200,"elapsed_time":7800,"total_elevation_gain":850,"average_speed":5.86}`))
require.NoError(t, err)
case "/activities/42/streams":
- _, err := w.Write([]byte(`{"latlng":{"data":[[43.0,5.0],[43.1,5.1]]},"altitude":{"data":[100,120]},"time":{"data":[0,10]}}`))
+ assert.Equal(t, "latlng,altitude,time,heartrate,cadence,watts", r.URL.Query().Get("keys"))
+ _, err := w.Write([]byte(`{"latlng":{"data":[[43.0,5.0],[43.1,5.1]]},"altitude":{"data":[100,120]},"time":{"data":[0,10]},"heartrate":{"data":[90,91]},"cadence":{"data":[70,null]},"watts":{"data":[200,0]}}`))
require.NoError(t, err)
default:
http.NotFound(w, r)
@@ -76,4 +79,36 @@ func TestGet(t *testing.T) {
parsed, err := gpx.ParseBytes(gpxData)
require.NoError(t, err)
require.Len(t, parsed.Tracks[0].Segments[0].Points, 2)
+ const garminNamespace = "http://www.garmin.com/xmlschemas/TrackPointExtension/v1"
+ firstExtension, found := parsed.Tracks[0].Segments[0].Points[0].Extensions.GetNode(gpx.NamespaceURL(garminNamespace), "TrackPointExtension")
+ require.True(t, found)
+ heartRate, found := firstExtension.GetNode("hr")
+ require.True(t, found)
+ assert.Equal(t, "90", heartRate.Data)
+ cadence, found := firstExtension.GetNode("cad")
+ require.True(t, found)
+ assert.Equal(t, "70", cadence.Data)
+ power, found := firstExtension.GetNode("watts")
+ require.True(t, found)
+ assert.Equal(t, "200", power.Data)
+
+ secondExtension, found := parsed.Tracks[0].Segments[0].Points[1].Extensions.GetNode(gpx.NamespaceURL(garminNamespace), "TrackPointExtension")
+ require.True(t, found)
+ _, found = secondExtension.GetNode("cad")
+ assert.False(t, found)
+ power, found = secondExtension.GetNode("watts")
+ require.True(t, found)
+ assert.Equal(t, "0", power.Data)
+
+ parsedRide, err := (ride.GPXRideParser{}).Parse(bytes.NewReader(gpxData))
+ require.NoError(t, err)
+ assert.Equal(t, 2, parsedRide.Len())
+ heartRateValue, found := parsedRide.HeartRateBpm(1)
+ require.True(t, found)
+ assert.Equal(t, 91.0, heartRateValue)
+ _, found = parsedRide.CadenceRpm(0)
+ assert.False(t, found)
+ powerValue, found := parsedRide.PowerW(1)
+ require.True(t, found)
+ assert.Equal(t, 0.0, powerValue)
}