package climb import ( "log/slog" "math" "slices" "github.com/martinlehoux/biking_home/geo" "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 []geo.Interval, minSlope float64) []DetectedClimb { distances, elevations, coords := route.Distances, route.Elevations, route.Coords bounds := make([]geo.Interval, 0) for _, region := range freeRegions(0, len(distances)-1, claimed) { bounds = append(bounds, climbsBetween(distances, elevations, geo.NewInterval(region.Start, region.End), minSlope)...) } slices.SortFunc(bounds, func(a, b geo.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 []geo.Interval) []geo.Interval { valid := make([]geo.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 geo.Interval) int { return a.Start - b.Start }) regions := make([]geo.Interval, 0, len(valid)+1) current := rStart for _, claim := range valid { if claim.End < current { continue } if claim.Start > current { regions = append(regions, geo.NewInterval(current, claim.Start-1)) } if claim.End+1 > current { current = claim.End + 1 } } if current <= rEnd { regions = append(regions, geo.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 geo.Interval, minSlope float64) []geo.Interval { start, end := bounds.Start, bounds.End climbs := []geo.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, geo.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, geo.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, geo.NewInterval(climbStart, climbEnd)) } climbs = append(climbs, climbsBetween(distances, elevations, geo.NewInterval(start, climbStart), minSlope)...) climbs = append(climbs, climbsBetween(distances, elevations, geo.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) (geo.Interval, bool) { end := len(distances) - 1 kcore.Assert(end > 0, "empty points") bestDetectionScore := climbDetectionScore(distances, elevations) best := geo.NewInterval(0, end) for i := range end { 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 = geo.NewInterval(-1, -1) for i := range end { 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 geo.Interval{}, false } return best, true }