From ff3cd6248f2b78dc76f1ce8cc2337907188fd401 Mon Sep 17 00:00:00 2001 From: Martin Kagamino Lehoux Date: Tue, 25 Aug 2026 11:24:51 +0200 Subject: Expose ride data as components and make algorithms consume only the slices they read Ride now exposes its columns as component getters (Distances, Elevations, Coords, Timestamps, and the optional Powers, Cadences, HeartRates) with the coords field unexported. difficultyScore, movingTime, Slope and the climb detection internals are pure functions over the specific slices they use, and Climb carries only the component slices it needs instead of a whole Ride. --- ride/climb.go | 126 +++++++++++++++++++++++++++++++++------------------------- 1 file changed, 72 insertions(+), 54 deletions(-) (limited to 'ride/climb.go') diff --git a/ride/climb.go b/ride/climb.go index 647cf9a..013104e 100644 --- a/ride/climb.go +++ b/ride/climb.go @@ -52,14 +52,17 @@ var categories = map[Category]categoryInfo{ const climbDetectionMinimumScore = 35 type Climb struct { - ride Ride - rideStart int - rideEnd int - Name string + distances []float64 + elevations []float64 + coords []geodist.Coord + timestamps []time.Time + rideStart int + rideEnd int + Name string } func (climb Climb) Duration() time.Duration { - return climb.ride.Timestamp(climb.rideEnd).Sub(climb.ride.Timestamp(climb.rideStart)) + return climb.timestamps[climb.rideEnd].Sub(climb.timestamps[climb.rideStart]) } func (climb Climb) Speed() float64 { @@ -71,31 +74,31 @@ func (climb Climb) StartIndex() int { return climb.rideStart } func (climb Climb) EndIndex() int { return climb.rideEnd } func (climb Climb) StartDistanceM() float64 { - return climb.ride.DistanceM(climb.rideStart) + return climb.distances[climb.rideStart] } func (climb Climb) EndDistanceM() float64 { - return climb.ride.DistanceM(climb.rideEnd) + return climb.distances[climb.rideEnd] } func (climb Climb) StartCoord() geodist.Coord { - return climb.ride.Coord(climb.rideStart) + return climb.coords[climb.rideStart] } func (climb Climb) EndCoord() geodist.Coord { - return climb.ride.Coord(climb.rideEnd) + 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.ride.Coord(index) + 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.ride.ElevationM(i) > climb.ride.ElevationM(top) { + if climb.elevations[i] > climb.elevations[top] { top = i } } @@ -103,22 +106,22 @@ func (climb Climb) TopIndex() int { } func (climb Climb) TopDistanceM() float64 { - return climb.ride.DistanceM(climb.TopIndex()) + return climb.distances[climb.TopIndex()] } func (climb Climb) TopElevationM() float64 { - return climb.ride.ElevationM(climb.TopIndex()) + return climb.elevations[climb.TopIndex()] } func (climb Climb) TopCoord() geodist.Coord { - return climb.ride.Coord(climb.TopIndex()) + 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.ride, climb.rideStart, climb.rideEnd)*100, int(cotacol), CategoryOf(cotacol)) + 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 } @@ -126,20 +129,20 @@ func (climb Climb) String() string { } func (climb Climb) DifficultyScore() float64 { - return difficultyScore(climb.ride, climb.rideStart, climb.rideEnd) + return difficultyScore(climb.distances, climb.elevations, climb.rideStart, climb.rideEnd) } -func Slope(r Ride, start, end int) float64 { - return (r.ElevationM(end) - r.ElevationM(start)) / (r.DistanceM(end) - r.DistanceM(start)) +func Slope(distances, elevations []float64, start, end int) float64 { + return (elevations[end] - elevations[start]) / (distances[end] - distances[start]) } -func climbDetectionScore(r Ride, start, end int) float64 { +func climbDetectionScore(distances, elevations []float64, start, end int) float64 { kcore.Assert(end > start, "no points for climb detection") - distance := r.DistanceM(end) - r.DistanceM(start) + distance := distances[end] - distances[start] if distance == 0 { return 0 } - dElevation := r.ElevationM(end) - r.ElevationM(start) + dElevation := elevations[end] - elevations[start] return math.Abs(dElevation) * dElevation / distance * 100.0 * 100.0 / 1000.0 } @@ -167,16 +170,21 @@ func CategoryOf(cotacol float64) Category { 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(r Ride, start, end int, minSlope float64) (Climb, bool) { +func bestClimbBetween(distances, elevations []float64, start, end int, minSlope float64) (interval, bool) { kcore.Assert(end > start, "empty points") - bestDetectionScore := climbDetectionScore(r, start, end) + bestDetectionScore := climbDetectionScore(distances, elevations, start, end) bestStart := start for i := start; i < end; i++ { - detectionScore := climbDetectionScore(r, i, end) + detectionScore := climbDetectionScore(distances, elevations, i, end) if detectionScore > bestDetectionScore { bestStart = i bestDetectionScore = detectionScore @@ -184,32 +192,32 @@ func bestClimbBetween(r Ride, start, end int, minSlope float64) (Climb, bool) { } bestEnd := end for i := end; i > bestStart; i-- { - detectionScore := climbDetectionScore(r, bestStart, i) + detectionScore := climbDetectionScore(distances, elevations, bestStart, i) if detectionScore > bestDetectionScore { bestEnd = i bestDetectionScore = detectionScore } } for i := bestStart; i < bestEnd; i++ { - detectionScore := climbDetectionScore(r, i, bestEnd) + detectionScore := climbDetectionScore(distances, elevations, i, bestEnd) if detectionScore > bestDetectionScore { bestStart = i bestDetectionScore = detectionScore } } kcore.Assert(bestStart < bestEnd, "empty climb") - if minSlope <= 0 || slopeBetween(r, bestStart, bestEnd) >= minSlope { - return Climb{ride: r, rideStart: bestStart, rideEnd: bestEnd}, true + 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 slopeBetween(r, i, j) < minSlope { + if Slope(distances, elevations, i, j) < minSlope { continue } - detectionScore := climbDetectionScore(r, i, j) + detectionScore := climbDetectionScore(distances, elevations, i, j) if detectionScore > bestDetectionScore { bestDetectionScore = detectionScore bestStart, bestEnd = i, j @@ -217,43 +225,40 @@ func bestClimbBetween(r Ride, start, end int, minSlope float64) (Climb, bool) { } } if bestStart < 0 { - return Climb{}, false + return interval{}, false } - return Climb{ride: r, rideStart: bestStart, rideEnd: bestEnd}, true -} - -// slopeBetween returns the average slope between two indexes. -func slopeBetween(r Ride, start, end int) float64 { - return (r.ElevationM(end) - r.ElevationM(start)) / (r.DistanceM(end) - r.DistanceM(start)) + return interval{bestStart, bestEnd}, true } -func climbsBetween(r Ride, start, end int, minSlope float64) []Climb { - climbs := []Climb{} - if r.DistanceM(end)-r.DistanceM(start) < ClimbDistanceMinimum { +// 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(r.DistanceM(start))), slog.Int("end", int(r.DistanceM(end)))) + 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 r.ElevationM(i) > r.ElevationM(highest) { + if elevations[i] > elevations[highest] { highest = i } } // TODO: Use descent to reduce recursion - if r.DistanceM(highest)-r.DistanceM(start) < ClimbDistanceMinimum { - return climbsBetween(r, start+1, end, minSlope) + if distances[highest]-distances[start] < ClimbDistanceMinimum { + return climbsBetween(distances, elevations, start+1, end, minSlope) } - climb, found := bestClimbBetween(r, start, highest, 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(r, highest, end, minSlope) + return climbsBetween(distances, elevations, highest, end, minSlope) } - if climbDetectionScore(r, climb.rideStart, climb.rideEnd) >= climbDetectionMinimumScore && climb.EndDistanceM()-climb.StartDistanceM() >= ClimbDistanceMinimum { - slog.Debug("Found climb between", slog.Int("start", int(climb.StartDistanceM())), slog.Int("end", int(climb.EndDistanceM()))) + 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(r, start, climb.rideStart, minSlope)...) - climbs = append(climbs, climbsBetween(r, climb.rideEnd, end, minSlope)...) + climbs = append(climbs, climbsBetween(distances, elevations, start, climb.start, minSlope)...) + climbs = append(climbs, climbsBetween(distances, elevations, climb.end, end, minSlope)...) return climbs } @@ -300,12 +305,25 @@ func freeRegions(rStart, rEnd int, claimed []Range) []Range { // 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 { - climbs := make([]Climb, 0) + bounds := make([]interval, 0) for _, region := range freeRegions(0, ride.Len()-1, claimed) { - climbs = append(climbs, climbsBetween(*ride, region.Start, region.End, minSlope)...) + 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)) } - slices.SortFunc(climbs, climbCmpStart) return climbs } -func climbCmpStart(a, b Climb) int { return a.rideStart - b.rideStart } +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