summaryrefslogtreecommitdiff
path: root/ride
diff options
context:
space:
mode:
authorMartin Kagamino Lehoux <martin@lehoux.net>2026-08-01 22:52:22 +0200
committerMartin Kagamino Lehoux <martin@lehoux.net>2026-08-01 22:52:22 +0200
commitb7ef44b22e23126a494614f93e07b248fd6a8f12 (patch)
tree145a5a3c73667c3d46c51a420cde0922fdb9d7ed /ride
parent80f2e72cd4818a4c0a5b5d4afb93dc23f04208e2 (diff)
feat: Add Cotacol difficulty score for rides
Diffstat (limited to 'ride')
-rw-r--r--ride/difficulty_test.go42
-rw-r--r--ride/ride.go40
2 files changed, 82 insertions, 0 deletions
diff --git a/ride/difficulty_test.go b/ride/difficulty_test.go
new file mode 100644
index 0000000..7e652cc
--- /dev/null
+++ b/ride/difficulty_test.go
@@ -0,0 +1,42 @@
+package ride_test
+
+import (
+ "testing"
+
+ "github.com/martinlehoux/biking_home/ride"
+ "github.com/stretchr/testify/assert"
+)
+
+func TestDifficultyScoreConstantClimb(t *testing.T) {
+ r := RideBuilder{precision: 100}.WithSection("2km at 7%").Build()
+ assert.InDelta(t, 98.0, r.DifficultyScore(), 1e-9)
+}
+
+func TestDifficultyScoreIgnoresDescent(t *testing.T) {
+ r := RideBuilder{precision: 100}.WithSection("2km at 7%").WithSection("2km at -7%").Build()
+ assert.InDelta(t, 98.0, r.DifficultyScore(), 1e-9)
+}
+
+func TestDifficultyScoreFlatIsZero(t *testing.T) {
+ r := RideBuilder{precision: 100}.WithSection("5km at 0%").Build()
+ assert.Zero(t, r.DifficultyScore())
+}
+
+func TestDifficultyScoreSteepShortCountsMore(t *testing.T) {
+ steep := RideBuilder{precision: 100}.WithSection("0.5km at 14%").Build()
+ long := RideBuilder{precision: 100}.WithSection("1km at 7%").Build()
+ assert.InDelta(t, 98.0, steep.DifficultyScore(), 1e-9)
+ assert.InDelta(t, 49.0, long.DifficultyScore(), 1e-9)
+}
+
+func TestDifficultyScorePrecisionInvariant(t *testing.T) {
+ coarse := RideBuilder{precision: 100}.WithSection("3km at 5%").Build()
+ fine := RideBuilder{precision: 50}.WithSection("3km at 5%").Build()
+ assert.InDelta(t, coarse.DifficultyScore(), fine.DifficultyScore(), 1e-9)
+}
+
+func TestDifficultyScoreExampleRide(t *testing.T) {
+ r, err := ride.ParseFile(parser, "../examples/2022-07-21.Pogacar.gpx")
+ assert.NoError(t, err)
+ assert.InDelta(t, 3049.0, r.DifficultyScore(), 1)
+}
diff --git a/ride/ride.go b/ride/ride.go
index f5a92a5..9ce260f 100644
--- a/ride/ride.go
+++ b/ride/ride.go
@@ -3,6 +3,7 @@ package ride
import (
"errors"
"io"
+ "math"
"os"
"time"
@@ -91,6 +92,45 @@ func (r *Ride) ScoreFromKm(start, end float64) float64 {
return Score(r.points, i, j)
}
+func (r *Ride) DifficultyScore() float64 {
+ points := r.points
+ if len(points) < 2 {
+ return 0
+ }
+ last := points[len(points)-1]
+ if last.DistanceM <= 0 {
+ return 0
+ }
+ score := 0.0
+ i := 0
+ for start := 0.0; start < last.DistanceM; start += 100 {
+ end := math.Min(start+100, last.DistanceM)
+ for i < len(points)-1 && points[i+1].DistanceM <= start {
+ i++
+ }
+ startElevation := interpolateElevation(points, i, start)
+ for i < len(points)-1 && points[i+1].DistanceM < end {
+ i++
+ }
+ endElevation := interpolateElevation(points, i, end)
+ slope := (endElevation - startElevation) / (end - start)
+ if slope > 0 {
+ score += (end - start) / 1000 * (slope * 100) * (slope * 100)
+ }
+ }
+ return score
+}
+
+func interpolateElevation(points []Point, i int, distance float64) float64 {
+ if i+1 >= len(points) {
+ return points[i].ElevationM
+ }
+ start := points[i]
+ end := points[i+1]
+ t := (distance - start.DistanceM) / (end.DistanceM - start.DistanceM)
+ return start.ElevationM + t*(end.ElevationM-start.ElevationM)
+}
+
func (r *Ride) ClimbFromDist(startDist, endDist float64) Climb {
start, end := 0, 0
for i, p := range r.points {