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/climb.go | |
| 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/climb.go')
| -rw-r--r-- | climb/climb.go | 87 |
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]) +} |
