summaryrefslogtreecommitdiff
path: root/ride/ride.go
diff options
context:
space:
mode:
authorMartin Kagamino Lehoux <martin@lehoux.net>2026-08-07 16:52:44 +0200
committerMartin Kagamino Lehoux <martin@lehoux.net>2026-08-07 16:52:44 +0200
commit5b74dc4321e63f251190a975b151875b64656c56 (patch)
treefbb7abb6076abcefbea5faeb599deeaf3e33c2ef /ride/ride.go
parent22a58e96dddd326f4c331c2a1bdea46f808ae3ad (diff)
feat: Add Strava stream metrics
Diffstat (limited to 'ride/ride.go')
-rw-r--r--ride/ride.go127
1 files changed, 123 insertions, 4 deletions
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