From 825cf388b55a6c8441e02149749bb6e2968477c0 Mon Sep 17 00:00:00 2001 From: Martin Kagamino Lehoux Date: Thu, 27 Aug 2026 11:21:17 +0200 Subject: 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. --- ride/climb.go | 329 ---------------------------------------------------------- 1 file changed, 329 deletions(-) delete mode 100644 ride/climb.go (limited to 'ride/climb.go') 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, - } -} -- cgit v1.3