aboutsummaryrefslogtreecommitdiff
path: root/climb/climb.go
diff options
context:
space:
mode:
authorMartin Kagamino Lehoux <martin@lehoux.net>2026-08-27 11:21:17 +0200
committerMartin Kagamino Lehoux <martin@lehoux.net>2026-08-27 11:21:17 +0200
commit825cf388b55a6c8441e02149749bb6e2968477c0 (patch)
treee3394cef97aa43df9775a1465d3c99f386ce3a9b /climb/climb.go
parent366e7b0d671395c2ad32f85ee7706a592db69d15 (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/climb.go')
-rw-r--r--climb/climb.go87
1 files changed, 87 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])
+}