aboutsummaryrefslogtreecommitdiff
path: root/ride/climb.go
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 /ride/climb.go
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.
Diffstat (limited to 'ride/climb.go')
-rw-r--r--ride/climb.go55
1 files changed, 46 insertions, 9 deletions
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