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, } }