diff options
| author | Martin Kagamino Lehoux <martin@lehoux.net> | 2026-08-01 22:52:22 +0200 |
|---|---|---|
| committer | Martin Kagamino Lehoux <martin@lehoux.net> | 2026-08-01 22:52:22 +0200 |
| commit | b7ef44b22e23126a494614f93e07b248fd6a8f12 (patch) | |
| tree | 145a5a3c73667c3d46c51a420cde0922fdb9d7ed /ride | |
| parent | 80f2e72cd4818a4c0a5b5d4afb93dc23f04208e2 (diff) | |
feat: Add Cotacol difficulty score for rides
Diffstat (limited to 'ride')
| -rw-r--r-- | ride/difficulty_test.go | 42 | ||||
| -rw-r--r-- | ride/ride.go | 40 |
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 { |