aboutsummaryrefslogtreecommitdiff
path: root/climb/detection.go
diff options
context:
space:
mode:
authorMartin Kagamino Lehoux <martin@lehoux.net>2026-08-27 11:21:17 +0200
committerMartin Kagamino Lehoux <martin@lehoux.net>2026-08-27 11:21:17 +0200
commit825cf388b55a6c8441e02149749bb6e2968477c0 (patch)
treee3394cef97aa43df9775a1465d3c99f386ce3a9b /climb/detection.go
parent366e7b0d671395c2ad32f85ee7706a592db69d15 (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/detection.go')
-rw-r--r--climb/detection.go175
1 files changed, 175 insertions, 0 deletions
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
+}