aboutsummaryrefslogtreecommitdiff
path: root/ride
diff options
context:
space:
mode:
authorMartin Kagamino Lehoux <martin@lehoux.net>2026-08-27 11:21:17 +0200
committerMartin Kagamino Lehoux <martin@lehoux.net>2026-08-27 11:21:17 +0200
commit825cf388b55a6c8441e02149749bb6e2968477c0 (patch)
treee3394cef97aa43df9775a1465d3c99f386ce3a9b /ride
parent366e7b0d671395c2ad32f85ee7706a592db69d15 (diff)
Extract climb detection into a dedicated climb package
Move climb detection, slope, and Cotacol scoring out of ride into a new climb package that operates on a geometry-only Route (distances, elevations, coords). Ride embeds climb.Route and keeps its timing and telemetry columns. - climb.Detect returns []DetectedClimb (Climb + ride-absolute Start). - Climb owns its sliced geometry; indices are local, with Start reported at the detection boundary for absolute position. - Detected bounds use core.Interval; freeRegions claim suppression fixed. - ride no longer builds slices: ClimbFromDist/ClimbFromIndexes/climbFrom and ride.Climb/Interval/Range are removed; rideanalysis, mountain_pass MatchClimb, and web views are rewired to climb types. - New internal/routetest builds climb.Route from the ride DSL; ridetest reuses it and adds timestamps and telemetry. - Tests moved to climb (synthetic detection and difficulty) and kept in ride for GPX-example integration; ride.comparison detection coverage retained. - mountain_pass.nearestCrossing now reuses geo.Nearest.
Diffstat (limited to 'ride')
-rw-r--r--ride/climb.go329
-rw-r--r--ride/climb_test.go135
-rw-r--r--ride/detection_example_test.go87
-rw-r--r--ride/difficulty_example_test.go25
-rw-r--r--ride/difficulty_test.go87
-rw-r--r--ride/ecs_test.go43
-rw-r--r--ride/moving_time.go2
-rw-r--r--ride/parser_test.go2
-rw-r--r--ride/ride.go109
-rw-r--r--ride/store.go7
-rw-r--r--ride/store_test.go11
11 files changed, 138 insertions, 699 deletions
diff --git a/ride/climb.go b/ride/climb.go
deleted file mode 100644
index 013104e..0000000
--- a/ride/climb.go
+++ /dev/null
@@ -1,329 +0,0 @@
-package ride
-
-import (
- "fmt"
- "log/slog"
- "math"
- "slices"
- "time"
-
- "github.com/jftuga/geodist"
- "github.com/martinlehoux/kagamigo/kcore"
-)
-
-const ClimbDistanceMinimum = 500
-
-// ClimbSlopeMinimum is the minimum average slope (ratio) of a detected climb;
-// gentler regions are re-segmented into their qualifying steeper parts.
-const ClimbSlopeMinimum = 0.035
-
-// Category is the difficulty class of a climb, ordered from easiest to
-// hardest.
-type Category int
-
-const (
- CategoryNo Category = iota
- CategoryFour
- CategoryThree
- CategoryTwo
- CategoryOne
- CategoryHC
-)
-
-type categoryInfo struct {
- label string
- minScore float64
-}
-
-// categories describes each Category: its display label and the inclusive
-// Cotacol score floor at which it starts applying. A score at or above
-// CategoryOne's floor is Hors Catégorie; CategoryNo has no floor.
-var categories = map[Category]categoryInfo{
- CategoryNo: {label: "NO", minScore: math.Inf(-1)},
- CategoryFour: {label: "Cat 4", minScore: 35},
- CategoryThree: {label: "Cat 3", minScore: 80},
- CategoryTwo: {label: "Cat 2", minScore: 180},
- CategoryOne: {label: "Cat 1", minScore: 250},
- CategoryHC: {label: "HC", minScore: 600},
-}
-
-// climbDetectionMinimumScore is the climb-detection score below which a
-// candidate segment is not reported as a climb.
-const climbDetectionMinimumScore = 35
-
-type Climb struct {
- distances []float64
- elevations []float64
- coords []geodist.Coord
- timestamps []time.Time
- rideStart int
- rideEnd int
- Name string
-}
-
-func (climb Climb) Duration() time.Duration {
- return climb.timestamps[climb.rideEnd].Sub(climb.timestamps[climb.rideStart])
-}
-
-func (climb Climb) Speed() float64 {
- return (climb.EndDistanceM() - climb.StartDistanceM()) / climb.Duration().Seconds()
-}
-
-func (climb Climb) StartIndex() int { return climb.rideStart }
-
-func (climb Climb) EndIndex() int { return climb.rideEnd }
-
-func (climb Climb) StartDistanceM() float64 {
- return climb.distances[climb.rideStart]
-}
-
-func (climb Climb) EndDistanceM() float64 {
- return climb.distances[climb.rideEnd]
-}
-
-func (climb Climb) StartCoord() geodist.Coord {
- return climb.coords[climb.rideStart]
-}
-
-func (climb Climb) EndCoord() geodist.Coord {
- return climb.coords[climb.rideEnd]
-}
-
-func (climb Climb) PointCoord(index int) geodist.Coord {
- kcore.Assert(index >= climb.rideStart && index <= climb.rideEnd, "point is outside climb")
- return climb.coords[index]
-}
-
-// TopIndex returns the highest point inside the climb, used as the crest anchor.
-func (climb Climb) TopIndex() int {
- top := climb.rideStart
- for i := climb.rideStart; i <= climb.rideEnd; i++ {
- if climb.elevations[i] > climb.elevations[top] {
- top = i
- }
- }
- return top
-}
-
-func (climb Climb) TopDistanceM() float64 {
- return climb.distances[climb.TopIndex()]
-}
-
-func (climb Climb) TopElevationM() float64 {
- return climb.elevations[climb.TopIndex()]
-}
-
-func (climb Climb) TopCoord() geodist.Coord {
- return climb.coords[climb.TopIndex()]
-}
-
-func (climb Climb) String() string {
- startDistance := climb.StartDistanceM()
- endDistance := climb.EndDistanceM()
- cotacol := climb.DifficultyScore()
- body := fmt.Sprintf("%.1fkm-%.1fkm: %.1fkm at %.1f%% (%d pts - %s)", startDistance/1000, endDistance/1000, (endDistance-startDistance)/1000, Slope(climb.distances, climb.elevations, climb.rideStart, climb.rideEnd)*100, int(cotacol), CategoryOf(cotacol))
- if climb.Name == "" {
- return body
- }
- return climb.Name + ": " + body
-}
-
-func (climb Climb) DifficultyScore() float64 {
- return difficultyScore(climb.distances, climb.elevations, climb.rideStart, climb.rideEnd)
-}
-
-func Slope(distances, elevations []float64, start, end int) float64 {
- return (elevations[end] - elevations[start]) / (distances[end] - distances[start])
-}
-
-func climbDetectionScore(distances, elevations []float64, start, end int) float64 {
- kcore.Assert(end > start, "no points for climb detection")
- distance := distances[end] - distances[start]
- if distance == 0 {
- return 0
- }
- dElevation := elevations[end] - elevations[start]
- return math.Abs(dElevation) * dElevation / distance * 100.0 * 100.0 / 1000.0
-}
-
-func (c Category) String() string {
- return categories[c].label
-}
-
-// CategoryOf returns the difficulty class of a Cotacol score: the category
-// with the highest floor the score reaches.
-func CategoryOf(cotacol float64) Category {
- best := CategoryNo
- bestMin := math.Inf(-1)
- found := false
- for category, info := range categories {
- if cotacol >= info.minScore && (!found || info.minScore > bestMin) {
- best = category
- bestMin = info.minScore
- found = true
- }
- }
- if !found {
- // NaN reaches no floor; preserve the original switch default (HC).
- return CategoryHC
- }
- return best
-}
-
-type interval struct {
- start int
- end int
-}
-
-// bestClimbBetween returns the highest-scoring climb in [start, end]; when
-// that climb averages below minSlope, it instead returns the best qualifying
-// sub-segment (found=false when none exists).
-func bestClimbBetween(distances, elevations []float64, start, end int, minSlope float64) (interval, bool) {
- kcore.Assert(end > start, "empty points")
-
- bestDetectionScore := climbDetectionScore(distances, elevations, start, end)
- bestStart := start
- for i := start; i < end; i++ {
- detectionScore := climbDetectionScore(distances, elevations, i, end)
- if detectionScore > bestDetectionScore {
- bestStart = i
- bestDetectionScore = detectionScore
- }
- }
- bestEnd := end
- for i := end; i > bestStart; i-- {
- detectionScore := climbDetectionScore(distances, elevations, bestStart, i)
- if detectionScore > bestDetectionScore {
- bestEnd = i
- bestDetectionScore = detectionScore
- }
- }
- for i := bestStart; i < bestEnd; i++ {
- detectionScore := climbDetectionScore(distances, elevations, i, bestEnd)
- if detectionScore > bestDetectionScore {
- bestStart = i
- bestDetectionScore = detectionScore
- }
- }
- kcore.Assert(bestStart < bestEnd, "empty climb")
- if minSlope <= 0 || Slope(distances, elevations, bestStart, bestEnd) >= minSlope {
- return interval{bestStart, bestEnd}, true
- }
- // Classic best too gentle: re-segment into the best qualifying sub-segment.
- bestDetectionScore = math.Inf(-1)
- bestStart, bestEnd = -1, -1
- for i := start; i < end; i++ {
- for j := i + 1; j <= end; j++ {
- if Slope(distances, elevations, i, j) < minSlope {
- continue
- }
- detectionScore := climbDetectionScore(distances, elevations, i, j)
- if detectionScore > bestDetectionScore {
- bestDetectionScore = detectionScore
- bestStart, bestEnd = i, j
- }
- }
- }
- if bestStart < 0 {
- return interval{}, false
- }
- return interval{bestStart, bestEnd}, true
-}
-
-// climbsBetween returns the detected climb bounds in [start, end] from the
-// distance and elevation components.
-func climbsBetween(distances, elevations []float64, start, end int, minSlope float64) []interval {
- climbs := []interval{}
- if distances[end]-distances[start] < ClimbDistanceMinimum {
- return climbs
- }
- slog.Debug("Searching climbs between", slog.Int("start", int(distances[start])), slog.Int("end", int(distances[end])))
- highest := start
- for i := start; i <= end; i++ {
- if elevations[i] > elevations[highest] {
- highest = i
- }
- }
- // TODO: Use descent to reduce recursion
- if distances[highest]-distances[start] < ClimbDistanceMinimum {
- return climbsBetween(distances, elevations, start+1, end, minSlope)
- }
- climb, found := bestClimbBetween(distances, elevations, start, highest, minSlope)
- if !found {
- // No qualifying climb up to the top; only the part past it can hold climbs.
- return climbsBetween(distances, elevations, highest, end, minSlope)
- }
- if climbDetectionScore(distances, elevations, climb.start, climb.end) >= climbDetectionMinimumScore && distances[climb.end]-distances[climb.start] >= ClimbDistanceMinimum {
- slog.Debug("Found climb between", slog.Int("start", int(distances[climb.start])), slog.Int("end", int(distances[climb.end])))
- climbs = append(climbs, climb)
- }
- climbs = append(climbs, climbsBetween(distances, elevations, start, climb.start, minSlope)...)
- climbs = append(climbs, climbsBetween(distances, elevations, climb.end, end, minSlope)...)
-
- return climbs
-}
-
-type Range struct {
- Start int
- End int
-}
-
-func isValidClaim(claim Range, routeLen int) bool {
- return claim.Start >= 0 && claim.End < routeLen && claim.Start < claim.End
-}
-
-// freeRegions returns the maximal sub-ranges of [rStart, rEnd] that are not
-// covered by any claimed range, after discarding claims that fall outside.
-func freeRegions(rStart, rEnd int, claimed []Range) []Range {
- valid := make([]Range, 0, len(claimed))
- for _, claim := range claimed {
- if !isValidClaim(claim, rEnd+1) {
- continue
- }
- valid = append(valid, claim)
- }
- slices.SortFunc(valid, func(a, b Range) int { return a.Start - b.Start })
- regions := make([]Range, 0, len(valid)+1)
- current := rStart
- for _, claim := range valid {
- if claim.End < current {
- continue
- }
- if claim.Start > current {
- regions = append(regions, Range{Start: current, End: claim.Start - 1})
- }
- if claim.End+1 > current {
- current = claim.End + 1
- }
- }
- if current <= rEnd {
- regions = append(regions, Range{Start: current, End: rEnd})
- }
- return regions
-}
-
-// returns the detected climbs of the whole ride, but never a climb that overlaps
-// a claimed range.
-func (ride *Ride) DetectClimbs(claimed []Range, minSlope float64) []Climb {
- bounds := make([]interval, 0)
- for _, region := range freeRegions(0, ride.Len()-1, claimed) {
- bounds = append(bounds, climbsBetween(ride.distances, ride.elevations, region.Start, region.End, minSlope)...)
- }
- slices.SortFunc(bounds, func(a, b interval) int { return a.start - b.start })
- climbs := make([]Climb, 0, len(bounds))
- for _, b := range bounds {
- climbs = append(climbs, climbFromBounds(ride, b))
- }
- return climbs
-}
-
-func climbFromBounds(ride *Ride, b interval) Climb {
- return Climb{
- distances: ride.distances,
- elevations: ride.elevations,
- coords: ride.coords,
- timestamps: ride.timestamps,
- rideStart: b.start,
- rideEnd: b.end,
- }
-}
diff --git a/ride/climb_test.go b/ride/climb_test.go
deleted file mode 100644
index 3632da7..0000000
--- a/ride/climb_test.go
+++ /dev/null
@@ -1,135 +0,0 @@
-package ride_test
-
-import (
- "testing"
-
- "github.com/martinlehoux/biking_home/internal/ridetest"
- "github.com/martinlehoux/biking_home/ride"
- "github.com/stretchr/testify/assert"
- "github.com/stretchr/testify/require"
-)
-
-var parser = ride.GPXRideParser{}
-
-func TestClimbThenFalseFlat(t *testing.T) {
- r := ridetest.MustRide("2km at 7% | 10km at 1%")
- climbs := r.DetectClimbs(nil, ride.ClimbSlopeMinimum)
-
- assert.Len(t, climbs, 1)
- assert.Equal(t, "0.0km-2.0km: 2.0km at 7.0% (98 pts - Cat 3)", climbs[0].String())
-}
-
-func TestSmallClimbWithDescentInsideFalseFlat(t *testing.T) {
- r := ridetest.MustRide("10km at 1% | 2km at 7% | 1km at -7% | 10km at 1%")
- climbs := r.DetectClimbs(nil, ride.ClimbSlopeMinimum)
-
- assert.Len(t, climbs, 1)
- assert.Equal(t, "10.0km-12.0km: 2.0km at 7.0% (98 pts - Cat 3)", climbs[0].String())
-}
-
-func TestPogacar20220721(t *testing.T) {
- r, err := ride.ParseFile(parser, "../examples/2022-07-21.Pogacar.gpx")
- assert.NoError(t, err)
- climbs := r.DetectClimbs(nil, ride.ClimbSlopeMinimum)
-
- assert.Len(t, climbs, 6)
- assert.Equal(t, "0.6km-5.6km: 5.0km at 3.5% (92 pts - Cat 3)", climbs[0].String())
- assert.Equal(t, "42.9km-45.2km: 2.3km at 5.0% (74 pts - Cat 4)", climbs[1].String())
- assert.Equal(t, "60.0km-76.4km: 16.4km at 7.2% (977 pts - HC)", climbs[2].String())
- assert.Equal(t, "83.8km-85.9km: 2.0km at 5.4% (79 pts - Cat 4)", climbs[3].String())
- assert.Equal(t, "99.2km-109.3km: 10.2km at 8.4% (751 pts - HC)", climbs[4].String())
- assert.Equal(t, "128.6km-142.2km: 13.6km at 7.8% (930 pts - HC)", climbs[5].String())
-}
-
-func TestBouclesVerdon2024(t *testing.T) {
- r, err := ride.ParseFile(parser, "../examples/2024-06-29.BouclesVerdon.gpx")
- assert.NoError(t, err)
- climbs := r.DetectClimbs(nil, ride.ClimbSlopeMinimum)
-
- assert.Len(t, climbs, 6)
- assert.Equal(t, "6.8km-9.1km: 2.3km at 4.0% (41 pts - Cat 4)", climbs[0].String())
- assert.Equal(t, "34.7km-37.3km: 2.5km at 5.0% (67 pts - Cat 4)", climbs[1].String())
- assert.Equal(t, "41.9km-43.8km: 1.8km at 4.6% (39 pts - Cat 4)", climbs[2].String())
- assert.Equal(t, "57.6km-60.8km: 3.1km at 6.3% (139 pts - Cat 3)", climbs[3].String())
- assert.Equal(t, "71.7km-74.7km: 3.0km at 5.3% (89 pts - Cat 3)", climbs[4].String())
- assert.Equal(t, "77.4km-78.7km: 1.3km at 5.3% (39 pts - Cat 4)", climbs[5].String())
-}
-
-func TestMimetArbois20241229(t *testing.T) {
- r, err := ride.ParseFile(parser, "../examples/2024-12-29.MimetArbois.gpx")
- assert.NoError(t, err)
- climbs := r.DetectClimbs(nil, ride.ClimbSlopeMinimum)
-
- assert.Len(t, climbs, 2)
- assert.Equal(t, "3.9km-5.3km: 1.4km at 10.3% (165 pts - Cat 3)", climbs[0].String())
- assert.Equal(t, "14.4km-19.8km: 5.4km at 4.3% (113 pts - Cat 3)", climbs[1].String())
-}
-
-func TestMimetArbois20250104(t *testing.T) {
- r, err := ride.ParseFile(parser, "../examples/2025-01-04.MimetArbois.gpx")
- assert.NoError(t, err)
- climbs := r.DetectClimbs(nil, ride.ClimbSlopeMinimum)
-
- assert.Len(t, climbs, 2)
- assert.Equal(t, "4.5km-5.9km: 1.4km at 10.1% (159 pts - Cat 3)", climbs[0].String())
- assert.Equal(t, "12.0km-15.1km: 3.2km at 4.5% (99 pts - Cat 3)", climbs[1].String())
-}
-
-// https://www.strava.com/activities/9282265844
-func TestAlpesVerdonTour20230617(t *testing.T) {
- r, err := ride.ParseFile(parser, "../examples/2023-06-17.AlpesVerdonTour.gpx")
- assert.NoError(t, err)
- climbs := r.DetectClimbs(nil, ride.ClimbSlopeMinimum)
-
- assert.Len(t, climbs, 6)
- assert.Equal(t, "0.7km-4.6km: 3.9km at 4.2% (72 pts - Cat 4)", climbs[0].String())
- assert.Equal(t, "8.2km-18.6km: 10.3km at 4.4% (292 pts - Cat 1)", climbs[1].String())
- assert.Equal(t, "36.1km-41.6km: 5.4km at 5.8% (193 pts - Cat 2)", climbs[2].String())
- assert.Equal(t, "47.0km-48.2km: 1.2km at 6.1% (45 pts - Cat 4)", climbs[3].String())
- assert.Equal(t, "51.9km-54.7km: 2.8km at 4.0% (48 pts - Cat 4)", climbs[4].String())
- assert.Equal(t, "114.1km-114.7km: 0.6km at 14.3% (119 pts - Cat 3)", climbs[5].String())
-}
-
-func TestDetectClimbsSkipsMiddleClaim(t *testing.T) {
- // A climb, a false flat giving a saddle, then a climb again. Claiming the
- // saddle's descent base should leave both climbs detected separately.
- r := ridetest.MustRide("2km at 7% | 1km at -7% | 2km at 7%")
- climbs := r.DetectClimbs([]ride.Range{{Start: 20, End: 20}}, ride.ClimbSlopeMinimum)
-
- // No free region falls under the min distance except the two climbs.
- assert.Len(t, climbs, 2)
- assert.Equal(t, "0.0km-2.0km: 2.0km at 7.0% (98 pts - Cat 3)", climbs[0].String())
- assert.Equal(t, "3.0km-5.0km: 2.0km at 7.0% (98 pts - Cat 3)", climbs[1].String())
-}
-
-func TestDetectClimbsOverlappingClaimDropsClimb(t *testing.T) {
- // Claiming the ascent region entirely swallows the climb.
- r := ridetest.MustRide("2km at 7% | 20km at 0%")
- climbs := r.DetectClimbs([]ride.Range{{Start: 0, End: 40}}, ride.ClimbSlopeMinimum)
- assert.Empty(t, climbs)
-}
-
-func TestDetectClimbsIgnoresOutOfRangeClaim(t *testing.T) {
- r := ridetest.MustRide("2km at 7% | 20km at 0%")
- climbs := r.DetectClimbs([]ride.Range{{Start: -5, End: -1}}, ride.ClimbSlopeMinimum)
- require.Len(t, climbs, 1)
- assert.Equal(t, "0.0km-2.0km: 2.0km at 7.0% (98 pts - Cat 3)", climbs[0].String())
-}
-
-func TestDetectClimbsWithUnsortedClaimsDropsOverlappingClimb(t *testing.T) {
- // Reproduces ride 6 Chemin des Lauriers bug: DetectClimbs must not depend on
- // caller-provided order. Two official claims out of ID order (Bastides
- // 2140-2627 before Lauriers 1215-1588) caused freeRegions to skip the
- // earlier claim and leave the overlapping Cat 4 1352-1601 visible.
- // Minimal reproduction: two climbs 0-2km and 3-5km separated by a descent.
- r := ridetest.MustRide("2km at 7% | 1km at -7% | 2km at 7%")
- // Both climbs claimed, but in reverse order (mimics ORDER BY id returning 30-50 before 0-20).
- // Sorted input correctly yields 0 detected climbs.
- sorted := []ride.Range{{Start: 0, End: 20}, {Start: 30, End: 50}}
- assert.Empty(t, r.DetectClimbs(sorted, ride.ClimbSlopeMinimum))
-
- unsorted := []ride.Range{{Start: 30, End: 50}, {Start: 0, End: 20}}
- climbs := r.DetectClimbs(unsorted, ride.ClimbSlopeMinimum)
- // Before fix this fails: freeRegions skips {0,20} because 20 < 30 and returns 1 climb.
- assert.Empty(t, climbs, "unsorted claimed ranges must still suppress overlapping climbs — expected 0, got %v", climbs)
-}
diff --git a/ride/detection_example_test.go b/ride/detection_example_test.go
new file mode 100644
index 0000000..053abbb
--- /dev/null
+++ b/ride/detection_example_test.go
@@ -0,0 +1,87 @@
+package ride_test
+
+import (
+ "fmt"
+ "testing"
+
+ "github.com/martinlehoux/biking_home/climb"
+ "github.com/martinlehoux/biking_home/ride"
+ "github.com/stretchr/testify/assert"
+)
+
+var parser = ride.GPXRideParser{}
+
+// describeClimb renders a detected climb as the ride-style
+// "start-end: length at slope (pts - cat)" string, so positions are asserted.
+func describeClimb(route climb.Route, detected climb.DetectedClimb) string {
+ seg := detected.Climb
+ startKm := route.Distances[detected.Start] / 1000
+ endKm := route.Distances[detected.Start+len(seg.Distances)-1] / 1000
+ lengthKm := (seg.Distances[len(seg.Distances)-1] - seg.Distances[0]) / 1000
+ slopePercent := climb.Slope(seg.Distances, seg.Elevations) * 100
+ pts := int(seg.DifficultyScore())
+ return fmt.Sprintf("%.1fkm-%.1fkm: %.1fkm at %.1f%% (%d pts - %s)", startKm, endKm, lengthKm, slopePercent, pts, climb.CategoryOf(seg.DifficultyScore()))
+}
+
+func TestPogacar20220721(t *testing.T) {
+ r, err := ride.ParseFile(parser, "../examples/2022-07-21.Pogacar.gpx")
+ assert.NoError(t, err)
+ detected := climb.Detect(r.Route, nil, climb.ClimbSlopeMinimum)
+
+ assert.Len(t, detected, 6)
+ assert.Equal(t, "0.6km-5.6km: 5.0km at 3.5% (92 pts - Cat 3)", describeClimb(r.Route, detected[0]))
+ assert.Equal(t, "42.9km-45.2km: 2.3km at 5.0% (74 pts - Cat 4)", describeClimb(r.Route, detected[1]))
+ assert.Equal(t, "60.0km-76.4km: 16.4km at 7.2% (977 pts - HC)", describeClimb(r.Route, detected[2]))
+ assert.Equal(t, "83.8km-85.9km: 2.1km at 5.4% (79 pts - Cat 4)", describeClimb(r.Route, detected[3]))
+ assert.Equal(t, "99.2km-109.3km: 10.2km at 8.4% (751 pts - HC)", describeClimb(r.Route, detected[4]))
+ assert.Equal(t, "128.6km-142.2km: 13.6km at 7.8% (930 pts - HC)", describeClimb(r.Route, detected[5]))
+}
+
+func TestBouclesVerdon2024(t *testing.T) {
+ r, err := ride.ParseFile(parser, "../examples/2024-06-29.BouclesVerdon.gpx")
+ assert.NoError(t, err)
+ detected := climb.Detect(r.Route, nil, climb.ClimbSlopeMinimum)
+
+ assert.Len(t, detected, 6)
+ assert.Equal(t, "6.8km-9.1km: 2.3km at 4.0% (41 pts - Cat 4)", describeClimb(r.Route, detected[0]))
+ assert.Equal(t, "34.7km-37.3km: 2.5km at 5.0% (67 pts - Cat 4)", describeClimb(r.Route, detected[1]))
+ assert.Equal(t, "41.9km-43.8km: 1.8km at 4.6% (39 pts - Cat 4)", describeClimb(r.Route, detected[2]))
+ assert.Equal(t, "57.6km-60.8km: 3.1km at 6.3% (139 pts - Cat 3)", describeClimb(r.Route, detected[3]))
+ assert.Equal(t, "71.7km-74.7km: 3.0km at 5.3% (90 pts - Cat 3)", describeClimb(r.Route, detected[4]))
+ assert.Equal(t, "77.4km-78.7km: 1.3km at 5.3% (39 pts - Cat 4)", describeClimb(r.Route, detected[5]))
+}
+
+func TestMimetArbois20241229(t *testing.T) {
+ r, err := ride.ParseFile(parser, "../examples/2024-12-29.MimetArbois.gpx")
+ assert.NoError(t, err)
+ detected := climb.Detect(r.Route, nil, climb.ClimbSlopeMinimum)
+
+ assert.Len(t, detected, 2)
+ assert.Equal(t, "3.9km-5.3km: 1.4km at 10.3% (165 pts - Cat 3)", describeClimb(r.Route, detected[0]))
+ assert.Equal(t, "14.4km-19.8km: 5.4km at 4.3% (113 pts - Cat 3)", describeClimb(r.Route, detected[1]))
+}
+
+func TestMimetArbois20250104(t *testing.T) {
+ r, err := ride.ParseFile(parser, "../examples/2025-01-04.MimetArbois.gpx")
+ assert.NoError(t, err)
+ detected := climb.Detect(r.Route, nil, climb.ClimbSlopeMinimum)
+
+ assert.Len(t, detected, 2)
+ assert.Equal(t, "4.5km-5.9km: 1.4km at 10.1% (159 pts - Cat 3)", describeClimb(r.Route, detected[0]))
+ assert.Equal(t, "12.0km-15.1km: 3.2km at 4.5% (99 pts - Cat 3)", describeClimb(r.Route, detected[1]))
+}
+
+// https://www.strava.com/activities/9282265844
+func TestAlpesVerdonTour20230617(t *testing.T) {
+ r, err := ride.ParseFile(parser, "../examples/2023-06-17.AlpesVerdonTour.gpx")
+ assert.NoError(t, err)
+ detected := climb.Detect(r.Route, nil, climb.ClimbSlopeMinimum)
+
+ assert.Len(t, detected, 6)
+ assert.Equal(t, "0.7km-4.6km: 3.9km at 4.2% (72 pts - Cat 4)", describeClimb(r.Route, detected[0]))
+ assert.Equal(t, "8.2km-18.6km: 10.3km at 4.4% (292 pts - Cat 1)", describeClimb(r.Route, detected[1]))
+ assert.Equal(t, "36.1km-41.6km: 5.4km at 5.8% (193 pts - Cat 2)", describeClimb(r.Route, detected[2]))
+ assert.Equal(t, "47.0km-48.2km: 1.2km at 6.1% (45 pts - Cat 4)", describeClimb(r.Route, detected[3]))
+ assert.Equal(t, "51.9km-54.7km: 2.8km at 4.0% (48 pts - Cat 4)", describeClimb(r.Route, detected[4]))
+ assert.Equal(t, "114.1km-114.7km: 0.6km at 14.3% (119 pts - Cat 3)", describeClimb(r.Route, detected[5]))
+}
diff --git a/ride/difficulty_example_test.go b/ride/difficulty_example_test.go
new file mode 100644
index 0000000..bd87f58
--- /dev/null
+++ b/ride/difficulty_example_test.go
@@ -0,0 +1,25 @@
+package ride_test
+
+import (
+ "testing"
+
+ "github.com/martinlehoux/biking_home/climb"
+ "github.com/martinlehoux/biking_home/ride"
+ "github.com/stretchr/testify/assert"
+)
+
+func TestCotacolExampleRide(t *testing.T) {
+ r, err := ride.ParseFile(parser, "../examples/2022-07-21.Pogacar.gpx")
+ assert.NoError(t, err)
+ assert.InDelta(t, 3049.0, climb.Cotacol(r.Distances, r.Elevations), 1)
+}
+
+func TestCotacolHautacamClimbfinderReference(t *testing.T) {
+ r, err := ride.ParseFile(parser, "../examples/2022-07-21.Pogacar.gpx")
+ assert.NoError(t, err)
+ detected := climb.Detect(r.Route, nil, climb.ClimbSlopeMinimum)
+ assert.Len(t, detected, 6)
+ hautacam := detected[5].Climb
+ assert.InDelta(t, 128.6, hautacam.Distances[0]/1000, 0.1)
+ assert.InDelta(t, 930.0, hautacam.DifficultyScore(), 10)
+}
diff --git a/ride/difficulty_test.go b/ride/difficulty_test.go
deleted file mode 100644
index bd316b4..0000000
--- a/ride/difficulty_test.go
+++ /dev/null
@@ -1,87 +0,0 @@
-package ride_test
-
-import (
- "math"
- "testing"
-
- "github.com/martinlehoux/biking_home/internal/ridetest"
- "github.com/martinlehoux/biking_home/ride"
- "github.com/stretchr/testify/assert"
-)
-
-func TestCotacolConstantClimb(t *testing.T) {
- r := ridetest.MustRide("2km at 7%")
- assert.InDelta(t, 98.0, ride.Cotacol(r), 1e-9)
-}
-
-func TestCotacolIgnoresDescent(t *testing.T) {
- r := ridetest.MustRide("2km at 7% | 2km at -7%")
- assert.InDelta(t, 98.0, ride.Cotacol(r), 1e-9)
-}
-
-func TestCotacolFlatIsZero(t *testing.T) {
- r := ridetest.MustRide("5km at 0%")
- assert.Zero(t, ride.Cotacol(r))
-}
-
-func TestCotacolSteepShortCountsMore(t *testing.T) {
- steep := ridetest.MustRide("0.5km at 14%")
- long := ridetest.MustRide("1km at 7%")
- assert.InDelta(t, 98.0, ride.Cotacol(steep), 1e-9)
- assert.InDelta(t, 49.0, ride.Cotacol(long), 1e-9)
-}
-
-func TestCotacolPrecisionInvariant(t *testing.T) {
- coarse := ridetest.MustRide("3km at 5%")
- fine := ridetest.MustRide("3km at 5%", ridetest.Sampling(50))
- assert.InDelta(t, ride.Cotacol(coarse), ride.Cotacol(fine), 1e-9)
-}
-
-func TestCotacolExampleRide(t *testing.T) {
- r, err := ride.ParseFile(parser, "../examples/2022-07-21.Pogacar.gpx")
- assert.NoError(t, err)
- assert.InDelta(t, 3049.0, ride.Cotacol(r), 1)
-}
-
-func TestCotacolClimbNotAtStart(t *testing.T) {
- r := ridetest.MustRide("1km at 0% | 2km at 7%")
- climbs := r.DetectClimbs(nil, ride.ClimbSlopeMinimum)
- assert.Len(t, climbs, 1)
- assert.InDelta(t, 98.0, climbs[0].DifficultyScore(), 1e-9)
-}
-
-func TestCategoryOfBoundaries(t *testing.T) {
- cases := []struct {
- score float64
- want ride.Category
- }{
- {-5, ride.CategoryNo},
- {30, ride.CategoryNo},
- {35, ride.CategoryFour}, // 35 is the first floor, not below it
- {79, ride.CategoryFour},
- {80, ride.CategoryThree},
- {179, ride.CategoryThree},
- {180, ride.CategoryTwo},
- {249, ride.CategoryTwo},
- {250, ride.CategoryOne},
- {599, ride.CategoryOne},
- {600, ride.CategoryHC},
- {700, ride.CategoryHC},
- {math.NaN(), ride.CategoryHC}, // reaches no floor, like the original switch default
- }
- for _, c := range cases {
- assert.Equal(t, c.want, ride.CategoryOf(c.score), "score %v", c.score)
- }
- assert.Equal(t, "Cat 1", ride.CategoryOne.String())
- assert.Equal(t, "NO", ride.CategoryNo.String())
-}
-
-func TestCotacolHautacamClimbfinderReference(t *testing.T) {
- r, err := ride.ParseFile(parser, "../examples/2022-07-21.Pogacar.gpx")
- assert.NoError(t, err)
- climbs := r.DetectClimbs(nil, ride.ClimbSlopeMinimum)
- assert.Len(t, climbs, 6)
- hautacam := climbs[5]
- assert.InDelta(t, 128.6, hautacam.StartDistanceM()/1000, 0.1)
- assert.InDelta(t, 930.0, hautacam.DifficultyScore(), 10)
-}
diff --git a/ride/ecs_test.go b/ride/ecs_test.go
deleted file mode 100644
index 01c0628..0000000
--- a/ride/ecs_test.go
+++ /dev/null
@@ -1,43 +0,0 @@
-package ride
-
-import (
- "testing"
- "time"
-
- "github.com/jftuga/geodist"
- "github.com/stretchr/testify/assert"
-)
-
-func TestRideExposesComponentGetters(t *testing.T) {
- distances := []float64{0, 500, 1000}
- elevations := []float64{0, 35, 70}
- coords := []geodist.Coord{{Lat: 43.0, Lon: 5.0}, {Lat: 43.1, Lon: 5.1}, {Lat: 43.2, Lon: 5.2}}
- timestamps := []time.Time{{}, time.Time{}.Add(time.Second), time.Time{}.Add(2 * time.Second)}
-
- r := FromColumns(distances, elevations, coords, timestamps)
-
- assert.Equal(t, distances, r.Distances())
- assert.Equal(t, elevations, r.Elevations())
- assert.Equal(t, coords, r.Coords())
- assert.Equal(t, timestamps, r.Timestamps())
-
- powers, ok := r.Powers()
- assert.False(t, ok, "no power component present")
- assert.Nil(t, powers)
- cadences, ok := r.Cadences()
- assert.False(t, ok, "no cadence component present")
- assert.Nil(t, cadences)
-}
-
-func TestDifficultyScoreReadsOnlyDistanceAndElevationComponents(t *testing.T) {
- // A 1 km climb at a steady 7%, sampled every 100 m.
- distances := make([]float64, 0, 11)
- elevations := make([]float64, 0, 11)
- for i := 0; i <= 10; i++ {
- distances = append(distances, float64(i*100))
- elevations = append(elevations, float64(i*7))
- }
-
- // Reference Cotacol value: 10 segments of 100 m at 7% => 49 pts.
- assert.InDelta(t, 49, difficultyScore(distances, elevations, 0, len(distances)-1), 0.5)
-}
diff --git a/ride/moving_time.go b/ride/moving_time.go
index 2ab07bf..62deb24 100644
--- a/ride/moving_time.go
+++ b/ride/moving_time.go
@@ -19,7 +19,7 @@ const movingTimeMaxGap = 120 * time.Second
// Bench (104 rides, `-backtest`, distance >= 10 km, moving/elapsed ratio >= 0.8):
// absMean 0.4%, stderr 0.1, p50 0.2, p75 0.4, p90 0.8, p95 1.4, p99 2.9.
func MovingTime(r Ride) time.Duration {
- return movingTime(r.timestamps, r.distances, r.powers, r.cadences, movingTimeMinSpeedMps, movingTimeMaxGap)
+ return movingTime(r.timestamps, r.Distances, r.powers, r.cadences, movingTimeMinSpeedMps, movingTimeMaxGap)
}
func movingTime(timestamps []time.Time, distances, powers, cadences []float64, minSpeedMps float64, maxGap time.Duration) time.Duration {
diff --git a/ride/parser_test.go b/ride/parser_test.go
index d8ac4d6..b9f0fb1 100644
--- a/ride/parser_test.go
+++ b/ride/parser_test.go
@@ -24,7 +24,7 @@ func TestGPXRideParserSkipsStationaryPoints(t *testing.T) {
parsed, err := (ride.GPXRideParser{}).Parse(strings.NewReader(data))
require.NoError(t, err)
assert.Equal(t, 2, parsed.Len())
- assert.Greater(t, parsed.Distances()[1], 0.0)
+ assert.Greater(t, parsed.Distances[1], 0.0)
}
func TestGPXRideParserRejectsStationaryRide(t *testing.T) {
diff --git a/ride/ride.go b/ride/ride.go
index 7863878..8e8bf95 100644
--- a/ride/ride.go
+++ b/ride/ride.go
@@ -4,13 +4,13 @@ import (
"errors"
"fmt"
"io"
- "math"
"os"
"strconv"
"strings"
"time"
"github.com/jftuga/geodist"
+ "github.com/martinlehoux/biking_home/climb"
"github.com/martinlehoux/kagamigo/kcore"
"github.com/tkrajina/gpxgo/gpx"
)
@@ -20,9 +20,7 @@ const garminTrackPointExtensionNamespace = "http://www.garmin.com/xmlschemas/Tra
var ErrNoTrackPoints = errors.New("ride has no track points")
type Ride struct {
- distances []float64
- elevations []float64
- coords []geodist.Coord
+ climb.Route
timestamps []time.Time
heartRates []float64
cadences []float64
@@ -31,26 +29,20 @@ type Ride struct {
func (r *Ride) check() {
kcore.Assert(r.Len() > 0, "no points in ride")
- 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")
+ 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")
+ kcore.Assert(len(r.Distances) == len(column), "ride columns have different lengths")
}
}
}
func (r Ride) Len() int {
- return len(r.distances)
+ return len(r.Distances)
}
-func (r Ride) Distances() []float64 { return r.distances }
-
-func (r Ride) Elevations() []float64 { return r.elevations }
-
-func (r Ride) Coords() []geodist.Coord { return r.coords }
-
func (r Ride) Timestamps() []time.Time { return r.timestamps }
func (r Ride) Powers() ([]float64, bool) { return optionalComponent(r.powers) }
@@ -170,9 +162,11 @@ func FromColumnsWithMetrics(distances []float64, elevations []float64, coords []
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,
+ Route: climb.Route{
+ Distances: distances,
+ Elevations: elevations,
+ Coords: coords,
+ },
timestamps: timestamps,
heartRates: heartRates,
cadences: cadences,
@@ -229,81 +223,6 @@ func metricValue(point gpx.GPXPoint, name string) (*float64, error) {
return &value, nil
}
-const CotacolAlgorithmVersion = "v1"
-
-// cotacolSegmentLengthM is the fixed length of the elevation segments the
-// Cotacol score is computed over.
-const cotacolSegmentLengthM = 100
-
-func Cotacol(ride Ride) float64 {
- return difficultyScore(ride.distances, ride.elevations, 0, ride.Len()-1)
-}
-
-func difficultyScore(distances, elevations []float64, startIndex, endIndex int) float64 {
- if endIndex-startIndex < 1 {
- return 0
- }
- startDistance := distances[startIndex]
- lastDistance := distances[endIndex]
- if lastDistance <= startDistance {
- return 0
- }
- cotacol := 0.0
- i := startIndex
- for start := startDistance; start < lastDistance; start += cotacolSegmentLengthM {
- end := math.Min(start+cotacolSegmentLengthM, lastDistance)
- for i < endIndex && distances[i+1] <= start {
- i++
- }
- startElevation := interpolateElevation(distances, elevations, i, start)
- for i < endIndex && distances[i+1] < end {
- i++
- }
- endElevation := interpolateElevation(distances, elevations, i, end)
- slope := (endElevation - startElevation) / (end - start)
- if slope > 0 {
- cotacol += (end - start) / 1000 * (slope * 100) * (slope * 100)
- }
- }
- return cotacol
-}
-
-func interpolateElevation(distances, elevations []float64, i int, distance float64) float64 {
- if i+1 >= len(distances) {
- return elevations[i]
- }
- startDistance := distances[i]
- endDistance := distances[i+1]
- t := (distance - startDistance) / (endDistance - startDistance)
- return elevations[i] + t*(elevations[i+1]-elevations[i])
-}
-
-func (r *Ride) ClimbFromDist(startDist, endDist float64) Climb {
- start, end := 0, 0
- for i, distance := range r.distances {
- if start == 0 && distance >= startDist {
- start = i
- }
- if end == 0 && distance >= endDist {
- end = i
- break
- }
- }
- return r.climbFrom(start, end)
-}
-
-func (r *Ride) ClimbFromIndexes(startIndex, endIndex int) Climb {
- kcore.Assert(startIndex >= 0 && endIndex < r.Len() && startIndex < endIndex, "invalid climb indexes")
- return r.climbFrom(startIndex, endIndex)
-}
-
-func (r *Ride) climbFrom(start, end int) Climb {
- return Climb{
- distances: r.distances,
- elevations: r.elevations,
- coords: r.coords,
- timestamps: r.timestamps,
- rideStart: start,
- rideEnd: end,
- }
+func (ride Ride) Cotacol() float64 {
+ return climb.Cotacol(ride.Distances, ride.Elevations)
}
diff --git a/ride/store.go b/ride/store.go
index 2d022ad..b906b42 100644
--- a/ride/store.go
+++ b/ride/store.go
@@ -7,6 +7,7 @@ import (
"strings"
"time"
+ "github.com/martinlehoux/biking_home/climb"
"github.com/martinlehoux/kagamigo/kcore"
)
@@ -57,8 +58,8 @@ func Save(db *sql.DB, r StoredRide) error {
if err != nil {
return fmt.Errorf("compute ride values for %q: %w", r.ExternalID, err)
}
- cotacolScore = Cotacol(parsed)
- cotacolAlgorithmVersion = CotacolAlgorithmVersion
+ cotacolScore = climb.Cotacol(parsed.Distances, parsed.Elevations)
+ cotacolAlgorithmVersion = climb.CotacolAlgorithmVersion
movingTimeS = int64(MovingTime(parsed) / time.Second)
movingTimeAlgorithmVersion = MovingTimeAlgorithmVersion
}
@@ -151,7 +152,7 @@ func ListSorted(db *sql.DB, column SortColumn, descending bool) ([]StoredRide, e
args = append(args, MovingTimeAlgorithmVersion)
} else if isComputedSort(column) {
query += "(cotacol_score IS NULL OR cotacol_algo_version IS NULL OR cotacol_algo_version <> ? OR distance_m <= 0) ASC, "
- args = append(args, CotacolAlgorithmVersion)
+ args = append(args, climb.CotacolAlgorithmVersion)
}
query += expression + " " + direction + ", id DESC"
rows, err := db.Query(query, args...)
diff --git a/ride/store_test.go b/ride/store_test.go
index 89c4830..1ee3c69 100644
--- a/ride/store_test.go
+++ b/ride/store_test.go
@@ -7,6 +7,7 @@ import (
"testing"
"time"
+ "github.com/martinlehoux/biking_home/climb"
"github.com/martinlehoux/biking_home/internal/dbtest"
"github.com/martinlehoux/kagamigo/kcore"
"github.com/stretchr/testify/assert"
@@ -53,7 +54,7 @@ func TestUpsertAndGet(t *testing.T) {
score, found := got.CotacolScore()
require.True(t, found)
assert.Greater(t, score, 0.0)
- assert.Equal(t, CotacolAlgorithmVersion, got.CotacolAlgorithmVersion())
+ assert.Equal(t, climb.CotacolAlgorithmVersion, got.CotacolAlgorithmVersion())
_, ok, err = GetByExternalID(db, "strava:9999")
require.NoError(t, err)
@@ -89,7 +90,7 @@ func TestSaveComputesCotacol(t *testing.T) {
score, found := got.CotacolScore()
require.True(t, found)
assert.Greater(t, score, 0.0)
- assert.Equal(t, CotacolAlgorithmVersion, got.CotacolAlgorithmVersion())
+ assert.Equal(t, climb.CotacolAlgorithmVersion, got.CotacolAlgorithmVersion())
}
func TestSaveComputesMovingTimeAndPreservesExternal(t *testing.T) {
@@ -190,7 +191,7 @@ func TestBackfill(t *testing.T) {
require.True(t, ok)
_, found := got.CotacolScore()
assert.True(t, found)
- assert.Equal(t, CotacolAlgorithmVersion, got.CotacolAlgorithmVersion())
+ assert.Equal(t, climb.CotacolAlgorithmVersion, got.CotacolAlgorithmVersion())
count, err = Backfill(db)
require.NoError(t, err)
@@ -277,7 +278,7 @@ func TestListSortedByCotacolKeepsStaleLast(t *testing.T) {
UPDATE rides
SET cotacol_score = CASE external_id WHEN ? THEN 4 ELSE 6 END,
cotacol_algo_version = ?
- `, first.ExternalID, CotacolAlgorithmVersion)
+ `, first.ExternalID, climb.CotacolAlgorithmVersion)
require.NoError(t, err)
items, err := ListSorted(db, SortCotacol, false)
@@ -307,7 +308,7 @@ func TestListSortedByCotacolPer100Km(t *testing.T) {
UPDATE rides
SET cotacol_score = CASE external_id WHEN ? THEN 4 ELSE 6 END,
cotacol_algo_version = ?
- `, first.ExternalID, CotacolAlgorithmVersion)
+ `, first.ExternalID, climb.CotacolAlgorithmVersion)
require.NoError(t, err)
items, err := ListSorted(db, SortCotacolKm, false)