diff options
| author | Martin Kagamino Lehoux <martin@lehoux.net> | 2026-08-21 22:09:21 +0200 |
|---|---|---|
| committer | Martin Kagamino Lehoux <martin@lehoux.net> | 2026-08-21 22:09:21 +0200 |
| commit | 765ea1fffcc1bd1af2e6e9645d6f138dfd4ae0eb (patch) | |
| tree | 391c294b47ba703acdd8ac687634b881cb31d90d /ride/climb.go | |
| parent | ed9516cbcb3c5254980a9e1dba7d8fa5ea230b35 (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.go | 55 |
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 |
