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 /climb | |
| 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 'climb')
| -rw-r--r-- | climb/climb.go | 87 | ||||
| -rw-r--r-- | climb/cotacol.go | 48 | ||||
| -rw-r--r-- | climb/detection.go | 175 | ||||
| -rw-r--r-- | climb/detection_test.go | 84 | ||||
| -rw-r--r-- | climb/difficulty_test.go | 72 | ||||
| -rw-r--r-- | climb/route.go | 9 |
6 files changed, 475 insertions, 0 deletions
diff --git a/climb/climb.go b/climb/climb.go new file mode 100644 index 0000000..2d764dd --- /dev/null +++ b/climb/climb.go @@ -0,0 +1,87 @@ +package climb + +import ( + "fmt" + "math" +) + +const ( + ClimbDistanceMinimum = 500 + ClimbSlopeMinimum = 0.035 + detectionMinimumScore = 35 +) + +type Category int + +const ( + CategoryNo Category = iota + CategoryFour + CategoryThree + CategoryTwo + CategoryOne + CategoryHC +) + +type categoryInfo struct { + label string + minScore float64 +} + +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}, +} + +func (c Category) String() string { + return categories[c].label +} + +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 Climb struct { + Route +} + +func (climb Climb) String() string { + cotacol := climb.DifficultyScore() + return fmt.Sprintf("%.1fkm at %.1f%% (%d pts - %s)", climb.Distances[len(climb.Distances)-1]/1000, Slope(climb.Distances, climb.Elevations)*100, int(cotacol), CategoryOf(cotacol)) +} + +func (climb Climb) DifficultyScore() float64 { + return Cotacol(climb.Distances, climb.Elevations) +} + +func (climb Climb) Top() (int, float64) { + top := 0 + for i := 1; i < len(climb.Elevations); i++ { + if climb.Elevations[i] > climb.Elevations[top] { + top = i + } + } + return top, climb.Elevations[top] +} + +func Slope(distances, elevations []float64) float64 { + end := len(distances) - 1 + return (elevations[end] - elevations[0]) / (distances[end] - distances[0]) +} diff --git a/climb/cotacol.go b/climb/cotacol.go new file mode 100644 index 0000000..85b096b --- /dev/null +++ b/climb/cotacol.go @@ -0,0 +1,48 @@ +package climb + +import "math" + +const ( + CotacolAlgorithmVersion = "v1" + cotacolSegmentLengthM = 100 +) + +func Cotacol(distances, elevations []float64) float64 { + startIndex, endIndex := 0, len(distances)-1 + 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]) +} diff --git a/climb/detection.go b/climb/detection.go new file mode 100644 index 0000000..1efe1c5 --- /dev/null +++ b/climb/detection.go @@ -0,0 +1,175 @@ +package climb + +import ( + "log/slog" + "math" + "slices" + + "github.com/martinlehoux/biking_home/core" + "github.com/martinlehoux/kagamigo/kcore" +) + +type DetectedClimb struct { + Climb Climb + Start int +} + +// returns the detected climbs but never a climb that overlaps a claimed range. +func Detect(route Route, claimed []core.Interval, minSlope float64) []DetectedClimb { + distances, elevations, coords := route.Distances, route.Elevations, route.Coords + bounds := make([]core.Interval, 0) + for _, region := range freeRegions(0, len(distances)-1, claimed) { + bounds = append(bounds, climbsBetween(distances, elevations, core.NewInterval(region.Start, region.End), minSlope)...) + } + slices.SortFunc(bounds, func(a, b core.Interval) int { return a.Start - b.Start }) + climbs := make([]DetectedClimb, 0, len(bounds)) + for _, b := range bounds { + climbs = append(climbs, DetectedClimb{ + Climb: Climb{Route: Route{ + Distances: distances[b.Start : b.End+1], + Elevations: elevations[b.Start : b.End+1], + Coords: coords[b.Start : b.End+1], + }}, + Start: b.Start, + }) + } + return climbs +} + +// 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 []core.Interval) []core.Interval { + valid := make([]core.Interval, 0, len(claimed)) + for _, claim := range claimed { + // Discard claims outside the route or with a degenerate range. + if claim.Start < 0 || claim.End >= rEnd+1 || claim.Start >= claim.End { + continue + } + valid = append(valid, claim) + } + slices.SortFunc(valid, func(a, b core.Interval) int { return a.Start - b.Start }) + regions := make([]core.Interval, 0, len(valid)+1) + current := rStart + for _, claim := range valid { + if claim.End < current { + continue + } + if claim.Start > current { + regions = append(regions, core.NewInterval(current, claim.Start-1)) + } + if claim.End+1 > current { + current = claim.End + 1 + } + } + if current <= rEnd { + regions = append(regions, core.NewInterval(current, rEnd)) + } + return regions +} + +// climbsBetween returns the detected climb bounds inside bounds from the +// distance and elevation components. +func climbsBetween(distances, elevations []float64, bounds core.Interval, minSlope float64) []core.Interval { + start, end := bounds.Start, bounds.End + climbs := []core.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, core.NewInterval(start+1, end), minSlope) + } + climb, found := bestClimbBetween(distances[start:highest+1], elevations[start:highest+1], minSlope) + if !found { + // No qualifying climb up to the top; only the part past it can hold climbs. + return climbsBetween(distances, elevations, core.NewInterval(highest, end), minSlope) + } + // bestClimbBetween returns an interval relative to its sliced input; + // re-anchor it into this slice's index space. + climbStart := start + climb.Start + climbEnd := start + climb.End + if climbDetectionScore(distances[climbStart:climbEnd+1], elevations[climbStart:climbEnd+1]) >= detectionMinimumScore && distances[climbEnd]-distances[climbStart] >= ClimbDistanceMinimum { + slog.Debug("Found climb between", slog.Int("start", int(distances[climbStart])), slog.Int("end", int(distances[climbEnd]))) + climbs = append(climbs, core.NewInterval(climbStart, climbEnd)) + } + climbs = append(climbs, climbsBetween(distances, elevations, core.NewInterval(start, climbStart), minSlope)...) + climbs = append(climbs, climbsBetween(distances, elevations, core.NewInterval(climbEnd, end), minSlope)...) + + return climbs +} + +func climbDetectionScore(distances, elevations []float64) float64 { + kcore.Assert(len(distances) > 0, "no points for climb detection") + end := len(distances) - 1 + distance := distances[end] - distances[0] + if distance == 0 { + return 0 + } + dElevation := elevations[end] - elevations[0] + return math.Abs(dElevation) * dElevation / distance * 100.0 * 100.0 / 1000.0 +} + +// bestClimbBetween returns the highest-scoring sub-interval of the segment +// given by [0, len-1]; when that climb averages below minSlope, it instead +// returns the best qualifying sub-segment (found=false when none exists). The +// returned interval is relative to the start of the given (already sliced) +// slices; callers add back their offset to recover absolute indices. +func bestClimbBetween(distances, elevations []float64, minSlope float64) (core.Interval, bool) { + end := len(distances) - 1 + kcore.Assert(end > 0, "empty points") + + bestDetectionScore := climbDetectionScore(distances, elevations) + best := core.NewInterval(0, end) + for i := 0; i < end; i++ { + detectionScore := climbDetectionScore(distances[i:end], elevations[i:end]) + if detectionScore > bestDetectionScore { + best.Start = i + bestDetectionScore = detectionScore + } + } + for i := end; i > best.Start; i-- { + detectionScore := climbDetectionScore(distances[best.Start:i+1], elevations[best.Start:i+1]) + if detectionScore > bestDetectionScore { + best.End = i + bestDetectionScore = detectionScore + } + } + for i := best.Start; i < best.End; i++ { + detectionScore := climbDetectionScore(distances[i:best.End], elevations[i:best.End]) + if detectionScore > bestDetectionScore { + best.Start = i + bestDetectionScore = detectionScore + } + } + kcore.Assert(best.Start < best.End, "empty climb") + if minSlope <= 0 || Slope(distances[best.Start:best.End+1], elevations[best.Start:best.End+1]) >= minSlope { + return best, true + } + // Classic best too gentle: re-segment into the best qualifying sub-segment. + bestDetectionScore = math.Inf(-1) + best = core.NewInterval(-1, -1) + for i := 0; i < end; i++ { + for j := i + 1; j <= end; j++ { + if Slope(distances[i:j+1], elevations[i:j+1]) < minSlope { + continue + } + detectionScore := climbDetectionScore(distances[i:j+1], elevations[i:j+1]) + if detectionScore > bestDetectionScore { + bestDetectionScore = detectionScore + best.Start = i + best.End = j + } + } + } + if best.Start < 0 { + return core.Interval{}, false + } + return best, true +} diff --git a/climb/detection_test.go b/climb/detection_test.go new file mode 100644 index 0000000..3c6a33b --- /dev/null +++ b/climb/detection_test.go @@ -0,0 +1,84 @@ +package climb_test + +import ( + "fmt" + "testing" + + "github.com/martinlehoux/biking_home/climb" + "github.com/martinlehoux/biking_home/core" + "github.com/martinlehoux/biking_home/internal/routetest" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// describe renders a detected climb as the ride-style +// "start-end: length at slope (pts - cat)" string, so positions are asserted. +func describe(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 TestClimbThenFalseFlat(t *testing.T) { + route := routetest.MustRoute("2km at 7% | 10km at 1%") + detected := climb.Detect(route, nil, climb.ClimbSlopeMinimum) + + assert.Len(t, detected, 1) + assert.Equal(t, "0.0km-2.0km: 2.0km at 7.0% (98 pts - Cat 3)", describe(route, detected[0])) +} + +func TestSmallClimbWithDescentInsideFalseFlat(t *testing.T) { + route := routetest.MustRoute("10km at 1% | 2km at 7% | 1km at -7% | 10km at 1%") + detected := climb.Detect(route, nil, climb.ClimbSlopeMinimum) + + assert.Len(t, detected, 1) + assert.Equal(t, "10.0km-12.0km: 2.0km at 7.0% (98 pts - Cat 3)", describe(route, detected[0])) +} + +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. + route := routetest.MustRoute("2km at 7% | 1km at -7% | 2km at 7%") + detected := climb.Detect(route, []core.Interval{core.NewInterval(20, 20)}, climb.ClimbSlopeMinimum) + + // No free region falls under the min distance except the two climbs. + assert.Len(t, detected, 2) + assert.Equal(t, "0.0km-2.0km: 2.0km at 7.0% (98 pts - Cat 3)", describe(route, detected[0])) + assert.Equal(t, "3.0km-5.0km: 2.0km at 7.0% (98 pts - Cat 3)", describe(route, detected[1])) +} + +func TestDetectClimbsOverlappingClaimDropsClimb(t *testing.T) { + // Claiming the ascent region entirely swallows the climb. + route := routetest.MustRoute("2km at 7% | 20km at 0%") + detected := climb.Detect(route, []core.Interval{core.NewInterval(0, 40)}, climb.ClimbSlopeMinimum) + assert.Empty(t, detected) +} + +func TestDetectClimbsIgnoresOutOfRangeClaim(t *testing.T) { + route := routetest.MustRoute("2km at 7% | 20km at 0%") + detected := climb.Detect(route, []core.Interval{core.NewInterval(-5, -1)}, climb.ClimbSlopeMinimum) + require.Len(t, detected, 1) + assert.Equal(t, "0.0km-2.0km: 2.0km at 7.0% (98 pts - Cat 3)", describe(route, detected[0])) +} + +func TestDetectClimbsWithUnsortedClaimsDropsOverlappingClimb(t *testing.T) { + // Reproduces ride 6 Chemin des Lauriers bug: Detect 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. + route := routetest.MustRoute("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 := []core.Interval{core.NewInterval(0, 20), core.NewInterval(30, 50)} + assert.Empty(t, climb.Detect(route, sorted, climb.ClimbSlopeMinimum)) + + unsorted := []core.Interval{core.NewInterval(30, 50), core.NewInterval(0, 20)} + detected := climb.Detect(route, unsorted, climb.ClimbSlopeMinimum) + // Before fix this fails: freeRegions skips {0,20} because 20 < 30 and returns 1 climb. + assert.Empty(t, detected, "unsorted claimed ranges must still suppress overlapping climbs — expected 0, got %v", detected) +} diff --git a/climb/difficulty_test.go b/climb/difficulty_test.go new file mode 100644 index 0000000..92c0a74 --- /dev/null +++ b/climb/difficulty_test.go @@ -0,0 +1,72 @@ +package climb_test + +import ( + "math" + "testing" + + "github.com/martinlehoux/biking_home/climb" + "github.com/martinlehoux/biking_home/internal/routetest" + "github.com/stretchr/testify/assert" +) + +func TestCotacolConstantClimb(t *testing.T) { + route := routetest.MustRoute("2km at 7%") + assert.InDelta(t, 98.0, climb.Cotacol(route.Distances, route.Elevations), 1e-9) +} + +func TestCotacolIgnoresDescent(t *testing.T) { + route := routetest.MustRoute("2km at 7% | 2km at -7%") + assert.InDelta(t, 98.0, climb.Cotacol(route.Distances, route.Elevations), 1e-9) +} + +func TestCotacolFlatIsZero(t *testing.T) { + route := routetest.MustRoute("5km at 0%") + assert.Zero(t, climb.Cotacol(route.Distances, route.Elevations)) +} + +func TestCotacolSteepShortCountsMore(t *testing.T) { + steep := routetest.MustRoute("0.5km at 14%") + long := routetest.MustRoute("1km at 7%") + assert.InDelta(t, 98.0, climb.Cotacol(steep.Distances, steep.Elevations), 1e-9) + assert.InDelta(t, 49.0, climb.Cotacol(long.Distances, long.Elevations), 1e-9) +} + +func TestCotacolPrecisionInvariant(t *testing.T) { + coarse := routetest.MustRoute("3km at 5%") + fine := routetest.MustRoute("3km at 5%", routetest.Sampling(50)) + assert.InDelta(t, climb.Cotacol(coarse.Distances, coarse.Elevations), climb.Cotacol(fine.Distances, fine.Elevations), 1e-9) +} + +func TestCotacolClimbNotAtStart(t *testing.T) { + route := routetest.MustRoute("1km at 0% | 2km at 7%") + detected := climb.Detect(route, nil, climb.ClimbSlopeMinimum) + assert.Len(t, detected, 1) + assert.InDelta(t, 98.0, detected[0].Climb.DifficultyScore(), 1e-9) +} + +func TestCategoryOfBoundaries(t *testing.T) { + cases := []struct { + score float64 + want climb.Category + }{ + {-5, climb.CategoryNo}, + {30, climb.CategoryNo}, + {35, climb.CategoryFour}, // 35 is the first floor, not below it + {79, climb.CategoryFour}, + {80, climb.CategoryThree}, + {179, climb.CategoryThree}, + {180, climb.CategoryTwo}, + {249, climb.CategoryTwo}, + {250, climb.CategoryOne}, + {599, climb.CategoryOne}, + {600, climb.CategoryHC}, + {700, climb.CategoryHC}, + {math.NaN(), climb.CategoryHC}, // reaches no floor, like the original switch default + } + + for _, c := range cases { + assert.Equal(t, c.want, climb.CategoryOf(c.score), "score %v", c.score) + } + assert.Equal(t, "Cat 1", climb.CategoryOne.String()) + assert.Equal(t, "NO", climb.CategoryNo.String()) +} diff --git a/climb/route.go b/climb/route.go new file mode 100644 index 0000000..80a9a43 --- /dev/null +++ b/climb/route.go @@ -0,0 +1,9 @@ +package climb + +import "github.com/jftuga/geodist" + +type Route struct { + Distances []float64 + Elevations []float64 + Coords []geodist.Coord +} |
