diff options
| author | Martin Kagamino Lehoux <martin@lehoux.net> | 2026-08-27 11:21:17 +0200 |
|---|---|---|
| committer | Martin Kagamino Lehoux <martin@lehoux.net> | 2026-08-27 11:21:17 +0200 |
| commit | 825cf388b55a6c8441e02149749bb6e2968477c0 (patch) | |
| tree | e3394cef97aa43df9775a1465d3c99f386ce3a9b /ride | |
| parent | 366e7b0d671395c2ad32f85ee7706a592db69d15 (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.go | 329 | ||||
| -rw-r--r-- | ride/climb_test.go | 135 | ||||
| -rw-r--r-- | ride/detection_example_test.go | 87 | ||||
| -rw-r--r-- | ride/difficulty_example_test.go | 25 | ||||
| -rw-r--r-- | ride/difficulty_test.go | 87 | ||||
| -rw-r--r-- | ride/ecs_test.go | 43 | ||||
| -rw-r--r-- | ride/moving_time.go | 2 | ||||
| -rw-r--r-- | ride/parser_test.go | 2 | ||||
| -rw-r--r-- | ride/ride.go | 109 | ||||
| -rw-r--r-- | ride/store.go | 7 | ||||
| -rw-r--r-- | ride/store_test.go | 11 |
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) |
