aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorMartin Kagamino Lehoux <martin@lehoux.net>2026-08-21 22:09:21 +0200
committerMartin Kagamino Lehoux <martin@lehoux.net>2026-08-21 22:09:21 +0200
commit765ea1fffcc1bd1af2e6e9645d6f138dfd4ae0eb (patch)
tree391c294b47ba703acdd8ac687634b881cb31d90d
parented9516cbcb3c5254980a9e1dba7d8fa5ea230b35 (diff)
Require a 3.5% minimum slope on detected climbs, re-segmenting gentler stretches
A detected climb must now average at least 3.5% slope with re-segmentation: gentle drags are split into their qualifying steeper parts instead of being reported wholesale or dropped. Qualifying climbs keep their previous boundaries exactly; only regions whose classic best climb averages below the threshold get re-segmented (full-scan for the best qualifying sub-segment, found=false when none exists). QA: A/B across data/rides (362 rides) went from 137 to 133 climbs, 23 rides changed; the 7 fully-dropped rides were audited to contain zero qualifying sub-segments. Example-ride tests updated for the new behavior; the resolved "minimum slope" TODO was removed.
-rw-r--r--README.md5
-rw-r--r--ride/climb.go55
-rw-r--r--ride/climb_test.go36
-rw-r--r--ride/difficulty_test.go4
-rw-r--r--rideanalysis/analysis.go2
5 files changed, 70 insertions, 32 deletions
diff --git a/README.md b/README.md
index 5cdf993..9341208 100644
--- a/README.md
+++ b/README.md
@@ -5,7 +5,7 @@ A Go toolkit for analyzing cycling rides from GPX exports: parse rides, detect c
## Features
- **GPX parsing** — reads elevation-tracked rides from Garmin/Strava GPX exports into a `Ride` model
-- **Climb detection** — automatically finds climbs within a ride, each scored and categorized (Cat 4 … HC)
+- **Climb detection** — automatically finds climbs within a ride, each scored and categorized (Cat 4 … HC), requiring at least a 3.5% average slope (gentler stretches are re-segmented into their qualifying parts)
- **Difficulty score (Cotacol)** — scores any ride or climb by splitting it into 100 m segments and summing `0.1 km × slope²` per segment. This fixed step is a deliberate compromise: smaller steps increase sensitivity to elevation noise, while unconstrained variable steps maximize noise rather than meaningful difficulty.
- **Similar-climb matching** — finds the same climb across rides by matching start/end coordinates, so times can be compared
- **Mountain pass extraction** — extracts French mountain passes from an OpenStreetMap PBF export (nodes tagged `mountain_pass=yes` / `natural=mountain_pass`) into a SQLite database
@@ -108,7 +108,8 @@ TypeScript checks can also be run directly with `npm run check`, covering format
## TODO
-- minimum slope of 3% or 4% help long slow climb to venelle
+- http://localhost:8080/rides/368: does not contain Pont Rout
+- http://localhost:8080/rides/6: Cat 4 inside Chemin des Lauriers (embrassé)
- Compute estimated power, speed
- Plot speed and slope per segment, colored by heart rate
- http://localhost:8080/rides/378: Repentance partial climb
diff --git a/ride/climb.go b/ride/climb.go
index 88b9d3b..f42a9a2 100644
--- a/ride/climb.go
+++ b/ride/climb.go
@@ -13,6 +13,10 @@ import (
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
@@ -163,7 +167,10 @@ func CategoryOf(cotacol float64) Category {
return best
}
-func bestClimbBetween(r Ride, start, end int) Climb {
+// 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(r Ride, start, end int, minSlope float64) (Climb, bool) {
kcore.Assert(end > start, "empty points")
bestDetectionScore := climbDetectionScore(r, start, end)
@@ -191,10 +198,36 @@ func bestClimbBetween(r Ride, start, end int) Climb {
}
}
kcore.Assert(bestStart < bestEnd, "empty climb")
- return Climb{ride: r, rideStart: bestStart, rideEnd: bestEnd}
+ if minSlope <= 0 || slopeBetween(r, bestStart, bestEnd) >= minSlope {
+ return Climb{ride: r, rideStart: bestStart, rideEnd: 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 slopeBetween(r, i, j) < minSlope {
+ continue
+ }
+ detectionScore := climbDetectionScore(r, i, j)
+ if detectionScore > bestDetectionScore {
+ bestDetectionScore = detectionScore
+ bestStart, bestEnd = i, j
+ }
+ }
+ }
+ if bestStart < 0 {
+ return Climb{}, false
+ }
+ return Climb{ride: r, rideStart: bestStart, rideEnd: bestEnd}, true
}
-func climbsBetween(r Ride, start, end int) []Climb {
+// slopeBetween returns the average slope between two indexes.
+func slopeBetween(r Ride, start, end int) float64 {
+ return (r.ElevationM(end) - r.ElevationM(start)) / (r.DistanceM(end) - r.DistanceM(start))
+}
+
+func climbsBetween(r Ride, start, end int, minSlope float64) []Climb {
climbs := []Climb{}
if r.DistanceM(end)-r.DistanceM(start) < ClimbDistanceMinimum {
return climbs
@@ -208,15 +241,19 @@ func climbsBetween(r Ride, start, end int) []Climb {
}
// TODO: Use descent to reduce recursion
if r.DistanceM(highest)-r.DistanceM(start) < ClimbDistanceMinimum {
- return climbsBetween(r, start+1, end)
+ return climbsBetween(r, start+1, end, minSlope)
+ }
+ climb, found := bestClimbBetween(r, start, highest, minSlope)
+ if !found {
+ // No qualifying climb up to the top; only the part past it can hold climbs.
+ return climbsBetween(r, highest, end, minSlope)
}
- climb := bestClimbBetween(r, start, highest)
if climbDetectionScore(r, climb.rideStart, climb.rideEnd) >= climbDetectionMinimumScore && climb.EndDistanceM()-climb.StartDistanceM() >= ClimbDistanceMinimum {
slog.Debug("Found climb between", slog.Int("start", int(climb.StartDistanceM())), slog.Int("end", int(climb.EndDistanceM())))
climbs = append(climbs, climb)
}
- climbs = append(climbs, climbsBetween(r, start, climb.rideStart)...)
- climbs = append(climbs, climbsBetween(r, climb.rideEnd, end)...)
+ climbs = append(climbs, climbsBetween(r, start, climb.rideStart, minSlope)...)
+ climbs = append(climbs, climbsBetween(r, climb.rideEnd, end, minSlope)...)
return climbs
}
@@ -257,10 +294,10 @@ func freeRegions(rStart, rEnd int, claimed []Range) []Range {
// returns the detected climbs of the whole ride, but never a climb that overlaps
// a claimed range.
-func (ride *Ride) DetectClimbs(claimed []Range) []Climb {
+func (ride *Ride) DetectClimbs(claimed []Range, minSlope float64) []Climb {
climbs := make([]Climb, 0)
for _, region := range freeRegions(0, ride.Len()-1, claimed) {
- climbs = append(climbs, climbsBetween(*ride, region.Start, region.End)...)
+ climbs = append(climbs, climbsBetween(*ride, region.Start, region.End, minSlope)...)
}
slices.SortFunc(climbs, climbCmpStart)
return climbs
diff --git a/ride/climb_test.go b/ride/climb_test.go
index a5e332d..c0d4bd6 100644
--- a/ride/climb_test.go
+++ b/ride/climb_test.go
@@ -67,7 +67,7 @@ var parser = ride.GPXRideParser{}
func TestClimbThenFalseFlat(t *testing.T) {
r := RideBuilder{precision: 100}.WithSection("2km at 7%").WithSection("10km at 1%").Build()
- climbs := r.DetectClimbs(nil)
+ 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())
@@ -75,7 +75,7 @@ func TestClimbThenFalseFlat(t *testing.T) {
func TestSmallClimbWithDescentInsideFalseFlat(t *testing.T) {
r := RideBuilder{precision: 100}.WithSection("10km at 1%").WithSection("2km at 7%").WithSection("1km at -7%").WithSection("10km at 1%").Build()
- climbs := r.DetectClimbs(nil)
+ 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())
@@ -84,7 +84,7 @@ func TestSmallClimbWithDescentInsideFalseFlat(t *testing.T) {
func TestPogacar20220721(t *testing.T) {
r, err := ride.ParseFile(parser, "../examples/2022-07-21.Pogacar.gpx")
assert.NoError(t, err)
- climbs := r.DetectClimbs(nil)
+ 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())
@@ -98,12 +98,12 @@ func TestPogacar20220721(t *testing.T) {
func TestBouclesVerdon2024(t *testing.T) {
r, err := ride.ParseFile(parser, "../examples/2024-06-29.BouclesVerdon.gpx")
assert.NoError(t, err)
- climbs := r.DetectClimbs(nil)
+ climbs := r.DetectClimbs(nil, ride.ClimbSlopeMinimum)
assert.Len(t, climbs, 6)
- assert.Equal(t, "3.6km-13.8km: 10.3km at 2.1% (98 pts - Cat 3)", climbs[0].String()) // TODO: Not steep enough
+ 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-51.0km: 9.0km at 2.5% (113 pts - Cat 3)", climbs[2].String()) // TODO: Not steep enough
+ 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())
@@ -112,7 +112,7 @@ func TestBouclesVerdon2024(t *testing.T) {
func TestMimetArbois20241229(t *testing.T) {
r, err := ride.ParseFile(parser, "../examples/2024-12-29.MimetArbois.gpx")
assert.NoError(t, err)
- climbs := r.DetectClimbs(nil)
+ 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())
@@ -122,25 +122,25 @@ func TestMimetArbois20241229(t *testing.T) {
func TestMimetArbois20250104(t *testing.T) {
r, err := ride.ParseFile(parser, "../examples/2025-01-04.MimetArbois.gpx")
assert.NoError(t, err)
- climbs := r.DetectClimbs(nil)
+ 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-20.2km: 8.2km at 2.9% (148 pts - Cat 3)", climbs[1].String()) // TODO: Not steep enough
+ 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)
+ climbs := r.DetectClimbs(nil, ride.ClimbSlopeMinimum)
assert.Len(t, climbs, 6)
- assert.Equal(t, "0.7km-18.6km: 17.9km at 3.5% (364 pts - Cat 1)", climbs[0].String()) // TODO: Should split in 2 (10km at 4.5% is Cat 2)
- assert.Equal(t, "36.1km-41.6km: 5.4km at 5.8% (193 pts - Cat 2)", climbs[1].String())
- assert.Equal(t, "47.0km-48.2km: 1.2km at 6.1% (45 pts - Cat 4)", climbs[2].String())
- assert.Equal(t, "51.9km-54.7km: 2.8km at 4.0% (48 pts - Cat 4)", climbs[3].String())
- assert.Equal(t, "86.7km-92.8km: 6.1km at 2.7% (60 pts - Cat 4)", climbs[4].String())
+ 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())
}
@@ -148,7 +148,7 @@ 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 := RideBuilder{precision: 100}.WithSection("2km at 7%").WithSection("1km at -7%").WithSection("2km at 7%").Build()
- climbs := r.DetectClimbs([]ride.Range{{Start: 20, End: 20}})
+ 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)
@@ -159,13 +159,13 @@ func TestDetectClimbsSkipsMiddleClaim(t *testing.T) {
func TestDetectClimbsOverlappingClaimDropsClimb(t *testing.T) {
// Claiming the ascent region entirely swallows the climb.
r := RideBuilder{precision: 100}.WithSection("2km at 7%").WithSection("20km at 0%").Build()
- climbs := r.DetectClimbs([]ride.Range{{Start: 0, End: 40}})
+ climbs := r.DetectClimbs([]ride.Range{{Start: 0, End: 40}}, ride.ClimbSlopeMinimum)
assert.Empty(t, climbs)
}
func TestDetectClimbsIgnoresOutOfRangeClaim(t *testing.T) {
r := RideBuilder{precision: 100}.WithSection("2km at 7%").WithSection("20km at 0%").Build()
- climbs := r.DetectClimbs([]ride.Range{{Start: -5, End: -1}})
+ 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())
}
diff --git a/ride/difficulty_test.go b/ride/difficulty_test.go
index 05b60f9..2e7cf56 100644
--- a/ride/difficulty_test.go
+++ b/ride/difficulty_test.go
@@ -44,7 +44,7 @@ func TestCotacolExampleRide(t *testing.T) {
func TestCotacolClimbNotAtStart(t *testing.T) {
r := RideBuilder{precision: 100}.WithSection("1km at 0%").WithSection("2km at 7%").Build()
- climbs := r.DetectClimbs(nil)
+ climbs := r.DetectClimbs(nil, ride.ClimbSlopeMinimum)
assert.Len(t, climbs, 1)
assert.InDelta(t, 98.0, climbs[0].DifficultyScore(), 1e-9)
}
@@ -78,7 +78,7 @@ func TestCategoryOfBoundaries(t *testing.T) {
func TestCotacolHautacamClimbfinderReference(t *testing.T) {
r, err := ride.ParseFile(parser, "../examples/2022-07-21.Pogacar.gpx")
assert.NoError(t, err)
- climbs := r.DetectClimbs(nil)
+ climbs := r.DetectClimbs(nil, ride.ClimbSlopeMinimum)
assert.Len(t, climbs, 6)
hautacam := climbs[5]
assert.InDelta(t, 128.6, hautacam.StartDistanceM()/1000, 0.1)
diff --git a/rideanalysis/analysis.go b/rideanalysis/analysis.go
index f055676..fae64b8 100644
--- a/rideanalysis/analysis.go
+++ b/rideanalysis/analysis.go
@@ -46,7 +46,7 @@ func Analyze(parsed ride.Ride, passes []mountain_pass.MountainPass, officialClim
claimedRanges = append(claimedRanges, ride.Range{Start: matched.StartIndex, End: matched.EndIndex})
claimedClimbs = append(claimedClimbs, analyzedClimb(parsed, parsed.ClimbFromIndexes(matched.StartIndex, matched.EndIndex), official.Name, official.ID))
}
- for _, detectedClimb := range parsed.DetectClimbs(claimedRanges) {
+ for _, detectedClimb := range parsed.DetectClimbs(claimedRanges, ride.ClimbSlopeMinimum) {
displayClimb := detectedClimb
if matchedPass, ok := mountain_pass.MatchClimb(displayClimb, passes, passMatchRadiusM, passMatchElevationToleranceM); ok {
displayClimb.Name = matchedPass.Name