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. --- climb/detection.go | 175 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 175 insertions(+) create mode 100644 climb/detection.go (limited to 'climb/detection.go') diff --git a/climb/detection.go b/climb/detection.go new file mode 100644 index 0000000..1efe1c5 --- /dev/null +++ b/climb/detection.go @@ -0,0 +1,175 @@ +package climb + +import ( + "log/slog" + "math" + "slices" + + "github.com/martinlehoux/biking_home/core" + "github.com/martinlehoux/kagamigo/kcore" +) + +type DetectedClimb struct { + Climb Climb + Start int +} + +// returns the detected climbs but never a climb that overlaps a claimed range. +func Detect(route Route, claimed []core.Interval, minSlope float64) []DetectedClimb { + distances, elevations, coords := route.Distances, route.Elevations, route.Coords + bounds := make([]core.Interval, 0) + for _, region := range freeRegions(0, len(distances)-1, claimed) { + bounds = append(bounds, climbsBetween(distances, elevations, core.NewInterval(region.Start, region.End), minSlope)...) + } + slices.SortFunc(bounds, func(a, b core.Interval) int { return a.Start - b.Start }) + climbs := make([]DetectedClimb, 0, len(bounds)) + for _, b := range bounds { + climbs = append(climbs, DetectedClimb{ + Climb: Climb{Route: Route{ + Distances: distances[b.Start : b.End+1], + Elevations: elevations[b.Start : b.End+1], + Coords: coords[b.Start : b.End+1], + }}, + Start: b.Start, + }) + } + return climbs +} + +// 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 []core.Interval) []core.Interval { + valid := make([]core.Interval, 0, len(claimed)) + for _, claim := range claimed { + // Discard claims outside the route or with a degenerate range. + if claim.Start < 0 || claim.End >= rEnd+1 || claim.Start >= claim.End { + continue + } + valid = append(valid, claim) + } + slices.SortFunc(valid, func(a, b core.Interval) int { return a.Start - b.Start }) + regions := make([]core.Interval, 0, len(valid)+1) + current := rStart + for _, claim := range valid { + if claim.End < current { + continue + } + if claim.Start > current { + regions = append(regions, core.NewInterval(current, claim.Start-1)) + } + if claim.End+1 > current { + current = claim.End + 1 + } + } + if current <= rEnd { + regions = append(regions, core.NewInterval(current, rEnd)) + } + return regions +} + +// climbsBetween returns the detected climb bounds inside bounds from the +// distance and elevation components. +func climbsBetween(distances, elevations []float64, bounds core.Interval, minSlope float64) []core.Interval { + start, end := bounds.Start, bounds.End + climbs := []core.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, core.NewInterval(start+1, end), minSlope) + } + climb, found := bestClimbBetween(distances[start:highest+1], elevations[start:highest+1], minSlope) + if !found { + // No qualifying climb up to the top; only the part past it can hold climbs. + return climbsBetween(distances, elevations, core.NewInterval(highest, end), minSlope) + } + // bestClimbBetween returns an interval relative to its sliced input; + // re-anchor it into this slice's index space. + climbStart := start + climb.Start + climbEnd := start + climb.End + if climbDetectionScore(distances[climbStart:climbEnd+1], elevations[climbStart:climbEnd+1]) >= detectionMinimumScore && distances[climbEnd]-distances[climbStart] >= ClimbDistanceMinimum { + slog.Debug("Found climb between", slog.Int("start", int(distances[climbStart])), slog.Int("end", int(distances[climbEnd]))) + climbs = append(climbs, core.NewInterval(climbStart, climbEnd)) + } + climbs = append(climbs, climbsBetween(distances, elevations, core.NewInterval(start, climbStart), minSlope)...) + climbs = append(climbs, climbsBetween(distances, elevations, core.NewInterval(climbEnd, end), minSlope)...) + + return climbs +} + +func climbDetectionScore(distances, elevations []float64) float64 { + kcore.Assert(len(distances) > 0, "no points for climb detection") + end := len(distances) - 1 + distance := distances[end] - distances[0] + if distance == 0 { + return 0 + } + dElevation := elevations[end] - elevations[0] + return math.Abs(dElevation) * dElevation / distance * 100.0 * 100.0 / 1000.0 +} + +// bestClimbBetween returns the highest-scoring sub-interval of the segment +// given by [0, len-1]; when that climb averages below minSlope, it instead +// returns the best qualifying sub-segment (found=false when none exists). The +// returned interval is relative to the start of the given (already sliced) +// slices; callers add back their offset to recover absolute indices. +func bestClimbBetween(distances, elevations []float64, minSlope float64) (core.Interval, bool) { + end := len(distances) - 1 + kcore.Assert(end > 0, "empty points") + + bestDetectionScore := climbDetectionScore(distances, elevations) + best := core.NewInterval(0, end) + for i := 0; i < end; i++ { + detectionScore := climbDetectionScore(distances[i:end], elevations[i:end]) + if detectionScore > bestDetectionScore { + best.Start = i + bestDetectionScore = detectionScore + } + } + for i := end; i > best.Start; i-- { + detectionScore := climbDetectionScore(distances[best.Start:i+1], elevations[best.Start:i+1]) + if detectionScore > bestDetectionScore { + best.End = i + bestDetectionScore = detectionScore + } + } + for i := best.Start; i < best.End; i++ { + detectionScore := climbDetectionScore(distances[i:best.End], elevations[i:best.End]) + if detectionScore > bestDetectionScore { + best.Start = i + bestDetectionScore = detectionScore + } + } + kcore.Assert(best.Start < best.End, "empty climb") + if minSlope <= 0 || Slope(distances[best.Start:best.End+1], elevations[best.Start:best.End+1]) >= minSlope { + return best, true + } + // Classic best too gentle: re-segment into the best qualifying sub-segment. + bestDetectionScore = math.Inf(-1) + best = core.NewInterval(-1, -1) + for i := 0; i < end; i++ { + for j := i + 1; j <= end; j++ { + if Slope(distances[i:j+1], elevations[i:j+1]) < minSlope { + continue + } + detectionScore := climbDetectionScore(distances[i:j+1], elevations[i:j+1]) + if detectionScore > bestDetectionScore { + bestDetectionScore = detectionScore + best.Start = i + best.End = j + } + } + } + if best.Start < 0 { + return core.Interval{}, false + } + return best, true +} -- cgit v1.3