aboutsummaryrefslogtreecommitdiff
path: root/climb
diff options
context:
space:
mode:
Diffstat (limited to 'climb')
-rw-r--r--climb/climb.go87
-rw-r--r--climb/cotacol.go48
-rw-r--r--climb/detection.go175
-rw-r--r--climb/detection_test.go84
-rw-r--r--climb/difficulty_test.go72
-rw-r--r--climb/route.go9
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
+}