aboutsummaryrefslogtreecommitdiff
path: root/ride/climb.go
diff options
context:
space:
mode:
Diffstat (limited to 'ride/climb.go')
-rw-r--r--ride/climb.go329
1 files changed, 0 insertions, 329 deletions
diff --git a/ride/climb.go b/ride/climb.go
deleted file mode 100644
index 013104e..0000000
--- a/ride/climb.go
+++ /dev/null
@@ -1,329 +0,0 @@
-package ride
-
-import (
- "fmt"
- "log/slog"
- "math"
- "slices"
- "time"
-
- "github.com/jftuga/geodist"
- "github.com/martinlehoux/kagamigo/kcore"
-)
-
-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
-
-const (
- CategoryNo Category = iota
- CategoryFour
- CategoryThree
- CategoryTwo
- CategoryOne
- CategoryHC
-)
-
-type categoryInfo struct {
- label string
- minScore float64
-}
-
-// categories describes each Category: its display label and the inclusive
-// Cotacol score floor at which it starts applying. A score at or above
-// CategoryOne's floor is Hors Catégorie; CategoryNo has no floor.
-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},
-}
-
-// climbDetectionMinimumScore is the climb-detection score below which a
-// candidate segment is not reported as a climb.
-const climbDetectionMinimumScore = 35
-
-type Climb struct {
- distances []float64
- elevations []float64
- coords []geodist.Coord
- timestamps []time.Time
- rideStart int
- rideEnd int
- Name string
-}
-
-func (climb Climb) Duration() time.Duration {
- return climb.timestamps[climb.rideEnd].Sub(climb.timestamps[climb.rideStart])
-}
-
-func (climb Climb) Speed() float64 {
- return (climb.EndDistanceM() - climb.StartDistanceM()) / climb.Duration().Seconds()
-}
-
-func (climb Climb) StartIndex() int { return climb.rideStart }
-
-func (climb Climb) EndIndex() int { return climb.rideEnd }
-
-func (climb Climb) StartDistanceM() float64 {
- return climb.distances[climb.rideStart]
-}
-
-func (climb Climb) EndDistanceM() float64 {
- return climb.distances[climb.rideEnd]
-}
-
-func (climb Climb) StartCoord() geodist.Coord {
- return climb.coords[climb.rideStart]
-}
-
-func (climb Climb) EndCoord() geodist.Coord {
- return climb.coords[climb.rideEnd]
-}
-
-func (climb Climb) PointCoord(index int) geodist.Coord {
- kcore.Assert(index >= climb.rideStart && index <= climb.rideEnd, "point is outside climb")
- return climb.coords[index]
-}
-
-// TopIndex returns the highest point inside the climb, used as the crest anchor.
-func (climb Climb) TopIndex() int {
- top := climb.rideStart
- for i := climb.rideStart; i <= climb.rideEnd; i++ {
- if climb.elevations[i] > climb.elevations[top] {
- top = i
- }
- }
- return top
-}
-
-func (climb Climb) TopDistanceM() float64 {
- return climb.distances[climb.TopIndex()]
-}
-
-func (climb Climb) TopElevationM() float64 {
- return climb.elevations[climb.TopIndex()]
-}
-
-func (climb Climb) TopCoord() geodist.Coord {
- return climb.coords[climb.TopIndex()]
-}
-
-func (climb Climb) String() string {
- startDistance := climb.StartDistanceM()
- endDistance := climb.EndDistanceM()
- cotacol := climb.DifficultyScore()
- body := fmt.Sprintf("%.1fkm-%.1fkm: %.1fkm at %.1f%% (%d pts - %s)", startDistance/1000, endDistance/1000, (endDistance-startDistance)/1000, Slope(climb.distances, climb.elevations, climb.rideStart, climb.rideEnd)*100, int(cotacol), CategoryOf(cotacol))
- if climb.Name == "" {
- return body
- }
- return climb.Name + ": " + body
-}
-
-func (climb Climb) DifficultyScore() float64 {
- return difficultyScore(climb.distances, climb.elevations, climb.rideStart, climb.rideEnd)
-}
-
-func Slope(distances, elevations []float64, start, end int) float64 {
- return (elevations[end] - elevations[start]) / (distances[end] - distances[start])
-}
-
-func climbDetectionScore(distances, elevations []float64, start, end int) float64 {
- kcore.Assert(end > start, "no points for climb detection")
- distance := distances[end] - distances[start]
- if distance == 0 {
- return 0
- }
- dElevation := elevations[end] - elevations[start]
- return math.Abs(dElevation) * dElevation / distance * 100.0 * 100.0 / 1000.0
-}
-
-func (c Category) String() string {
- return categories[c].label
-}
-
-// CategoryOf returns the difficulty class of a Cotacol score: the category
-// with the highest floor the score reaches.
-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 interval struct {
- start int
- end int
-}
-
-// 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(distances, elevations []float64, start, end int, minSlope float64) (interval, bool) {
- kcore.Assert(end > start, "empty points")
-
- bestDetectionScore := climbDetectionScore(distances, elevations, start, end)
- bestStart := start
- for i := start; i < end; i++ {
- detectionScore := climbDetectionScore(distances, elevations, i, end)
- if detectionScore > bestDetectionScore {
- bestStart = i
- bestDetectionScore = detectionScore
- }
- }
- bestEnd := end
- for i := end; i > bestStart; i-- {
- detectionScore := climbDetectionScore(distances, elevations, bestStart, i)
- if detectionScore > bestDetectionScore {
- bestEnd = i
- bestDetectionScore = detectionScore
- }
- }
- for i := bestStart; i < bestEnd; i++ {
- detectionScore := climbDetectionScore(distances, elevations, i, bestEnd)
- if detectionScore > bestDetectionScore {
- bestStart = i
- bestDetectionScore = detectionScore
- }
- }
- kcore.Assert(bestStart < bestEnd, "empty climb")
- if minSlope <= 0 || Slope(distances, elevations, bestStart, bestEnd) >= minSlope {
- return interval{bestStart, 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 Slope(distances, elevations, i, j) < minSlope {
- continue
- }
- detectionScore := climbDetectionScore(distances, elevations, i, j)
- if detectionScore > bestDetectionScore {
- bestDetectionScore = detectionScore
- bestStart, bestEnd = i, j
- }
- }
- }
- if bestStart < 0 {
- return interval{}, false
- }
- return interval{bestStart, bestEnd}, true
-}
-
-// climbsBetween returns the detected climb bounds in [start, end] from the
-// distance and elevation components.
-func climbsBetween(distances, elevations []float64, start, end int, minSlope float64) []interval {
- climbs := []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, start+1, end, minSlope)
- }
- climb, found := bestClimbBetween(distances, elevations, start, highest, minSlope)
- if !found {
- // No qualifying climb up to the top; only the part past it can hold climbs.
- return climbsBetween(distances, elevations, highest, end, minSlope)
- }
- if climbDetectionScore(distances, elevations, climb.start, climb.end) >= climbDetectionMinimumScore && distances[climb.end]-distances[climb.start] >= ClimbDistanceMinimum {
- slog.Debug("Found climb between", slog.Int("start", int(distances[climb.start])), slog.Int("end", int(distances[climb.end])))
- climbs = append(climbs, climb)
- }
- climbs = append(climbs, climbsBetween(distances, elevations, start, climb.start, minSlope)...)
- climbs = append(climbs, climbsBetween(distances, elevations, climb.end, end, minSlope)...)
-
- return climbs
-}
-
-type Range struct {
- Start int
- End int
-}
-
-func isValidClaim(claim Range, routeLen int) bool {
- return claim.Start >= 0 && claim.End < routeLen && claim.Start < claim.End
-}
-
-// 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 []Range) []Range {
- valid := make([]Range, 0, len(claimed))
- for _, claim := range claimed {
- if !isValidClaim(claim, rEnd+1) {
- continue
- }
- valid = append(valid, claim)
- }
- slices.SortFunc(valid, func(a, b Range) int { return a.Start - b.Start })
- regions := make([]Range, 0, len(valid)+1)
- current := rStart
- for _, claim := range valid {
- if claim.End < current {
- continue
- }
- if claim.Start > current {
- regions = append(regions, Range{Start: current, End: claim.Start - 1})
- }
- if claim.End+1 > current {
- current = claim.End + 1
- }
- }
- if current <= rEnd {
- regions = append(regions, Range{Start: current, End: rEnd})
- }
- return regions
-}
-
-// returns the detected climbs of the whole ride, but never a climb that overlaps
-// a claimed range.
-func (ride *Ride) DetectClimbs(claimed []Range, minSlope float64) []Climb {
- bounds := make([]interval, 0)
- for _, region := range freeRegions(0, ride.Len()-1, claimed) {
- bounds = append(bounds, climbsBetween(ride.distances, ride.elevations, region.Start, region.End, minSlope)...)
- }
- slices.SortFunc(bounds, func(a, b interval) int { return a.start - b.start })
- climbs := make([]Climb, 0, len(bounds))
- for _, b := range bounds {
- climbs = append(climbs, climbFromBounds(ride, b))
- }
- return climbs
-}
-
-func climbFromBounds(ride *Ride, b interval) Climb {
- return Climb{
- distances: ride.distances,
- elevations: ride.elevations,
- coords: ride.coords,
- timestamps: ride.timestamps,
- rideStart: b.start,
- rideEnd: b.end,
- }
-}