diff options
| author | Martin Kagamino Lehoux <martin@lehoux.net> | 2026-08-07 16:52:44 +0200 |
|---|---|---|
| committer | Martin Kagamino Lehoux <martin@lehoux.net> | 2026-08-07 16:52:44 +0200 |
| commit | 5b74dc4321e63f251190a975b151875b64656c56 (patch) | |
| tree | fbb7abb6076abcefbea5faeb599deeaf3e33c2ef | |
| parent | 22a58e96dddd326f4c331c2a1bdea46f808ae3ad (diff) | |
feat: Add Strava stream metrics
| -rw-r--r-- | ride/parser_test.go | 42 | ||||
| -rw-r--r-- | ride/ride.go | 127 | ||||
| -rw-r--r-- | strava/sync.go | 85 | ||||
| -rw-r--r-- | strava/sync_test.go | 37 |
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) } |