diff options
Diffstat (limited to 'ride')
| -rw-r--r-- | ride/climb.go | 126 | ||||
| -rw-r--r-- | ride/ecs_test.go | 43 | ||||
| -rw-r--r-- | ride/moving_time.go | 18 | ||||
| -rw-r--r-- | ride/ride.go | 72 |
4 files changed, 176 insertions, 83 deletions
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, + } +} diff --git a/ride/ecs_test.go b/ride/ecs_test.go new file mode 100644 index 0000000..01c0628 --- /dev/null +++ b/ride/ecs_test.go @@ -0,0 +1,43 @@ +package ride + +import ( + "testing" + "time" + + "github.com/jftuga/geodist" + "github.com/stretchr/testify/assert" +) + +func TestRideExposesComponentGetters(t *testing.T) { + distances := []float64{0, 500, 1000} + elevations := []float64{0, 35, 70} + coords := []geodist.Coord{{Lat: 43.0, Lon: 5.0}, {Lat: 43.1, Lon: 5.1}, {Lat: 43.2, Lon: 5.2}} + timestamps := []time.Time{{}, time.Time{}.Add(time.Second), time.Time{}.Add(2 * time.Second)} + + r := FromColumns(distances, elevations, coords, timestamps) + + assert.Equal(t, distances, r.Distances()) + assert.Equal(t, elevations, r.Elevations()) + assert.Equal(t, coords, r.Coords()) + assert.Equal(t, timestamps, r.Timestamps()) + + powers, ok := r.Powers() + assert.False(t, ok, "no power component present") + assert.Nil(t, powers) + cadences, ok := r.Cadences() + assert.False(t, ok, "no cadence component present") + assert.Nil(t, cadences) +} + +func TestDifficultyScoreReadsOnlyDistanceAndElevationComponents(t *testing.T) { + // A 1 km climb at a steady 7%, sampled every 100 m. + distances := make([]float64, 0, 11) + elevations := make([]float64, 0, 11) + for i := 0; i <= 10; i++ { + distances = append(distances, float64(i*100)) + elevations = append(elevations, float64(i*7)) + } + + // Reference Cotacol value: 10 segments of 100 m at 7% => 49 pts. + assert.InDelta(t, 49, difficultyScore(distances, elevations, 0, len(distances)-1), 0.5) +} diff --git a/ride/moving_time.go b/ride/moving_time.go index 84cc28e..2ab07bf 100644 --- a/ride/moving_time.go +++ b/ride/moving_time.go @@ -19,25 +19,25 @@ const movingTimeMaxGap = 120 * time.Second // Bench (104 rides, `-backtest`, distance >= 10 km, moving/elapsed ratio >= 0.8): // absMean 0.4%, stderr 0.1, p50 0.2, p75 0.4, p90 0.8, p95 1.4, p99 2.9. func MovingTime(r Ride) time.Duration { - return movingTime(r, movingTimeMinSpeedMps, movingTimeMaxGap) + return movingTime(r.timestamps, r.distances, r.powers, r.cadences, movingTimeMinSpeedMps, movingTimeMaxGap) } -func movingTime(r Ride, minSpeedMps float64, maxGap time.Duration) time.Duration { +func movingTime(timestamps []time.Time, distances, powers, cadences []float64, minSpeedMps float64, maxGap time.Duration) time.Duration { var moving time.Duration - for i := 1; i < r.Len(); i++ { - dt := r.Timestamp(i).Sub(r.Timestamp(i - 1)) + for i := 1; i < len(timestamps); i++ { + dt := timestamps[i].Sub(timestamps[i-1]) if dt <= 0 || dt > maxGap { continue } - dd := r.DistanceM(i) - r.DistanceM(i-1) + dd := distances[i] - distances[i-1] gpsMoving := dd/dt.Seconds() > minSpeedMps powerMoving := false - if power, ok := r.PowerW(i); ok { - powerMoving = power >= movingTimeMinPowerW + if len(powers) > 0 { + powerMoving = powers[i] >= movingTimeMinPowerW } cadenceMoving := false - if cadence, ok := r.CadenceRpm(i); ok { - cadenceMoving = cadence > 0 + if len(cadences) > 0 { + cadenceMoving = cadences[i] > 0 } if gpsMoving || powerMoving || cadenceMoving { moving += dt diff --git a/ride/ride.go b/ride/ride.go index d5533ae..a694df9 100644 --- a/ride/ride.go +++ b/ride/ride.go @@ -22,7 +22,7 @@ var ErrNoTrackPoints = errors.New("ride has no track points") type Ride struct { distances []float64 elevations []float64 - Coords []geodist.Coord + coords []geodist.Coord timestamps []time.Time heartRates []float64 cadences []float64 @@ -32,7 +32,7 @@ type Ride struct { func (r *Ride) check() { kcore.Assert(r.Len() > 0, "no points in ride") kcore.Assert(len(r.distances) == len(r.elevations), "ride columns have different lengths") - kcore.Assert(len(r.distances) == len(r.Coords), "ride columns have different lengths") + kcore.Assert(len(r.distances) == len(r.coords), "ride columns have different lengths") kcore.Assert(len(r.distances) == len(r.timestamps), "ride columns have different lengths") for _, column := range [][]float64{r.heartRates, r.cadences, r.powers} { if len(column) > 0 { @@ -45,11 +45,25 @@ func (r Ride) Len() int { return len(r.distances) } +func (r Ride) Distances() []float64 { return r.distances } + +func (r Ride) Elevations() []float64 { return r.elevations } + +func (r Ride) Coords() []geodist.Coord { return r.coords } + +func (r Ride) Timestamps() []time.Time { return r.timestamps } + +func (r Ride) Powers() ([]float64, bool) { return optionalComponent(r.powers) } + +func (r Ride) Cadences() ([]float64, bool) { return optionalComponent(r.cadences) } + +func (r Ride) HeartRates() ([]float64, bool) { return optionalComponent(r.heartRates) } + func (r Ride) DistanceM(i int) float64 { return r.distances[i] } func (r Ride) ElevationM(i int) float64 { return r.elevations[i] } -func (r Ride) Coord(i int) geodist.Coord { return r.Coords[i] } +func (r Ride) Coord(i int) geodist.Coord { return r.coords[i] } func (r Ride) Timestamp(i int) time.Time { return r.timestamps[i] } @@ -65,6 +79,13 @@ func (r Ride) PowerW(i int) (float64, bool) { return optionalMetric(r.powers, i) } +func optionalComponent(column []float64) ([]float64, bool) { + if len(column) == 0 { + return nil, false + } + return column, true +} + func optionalMetric(column []float64, i int) (float64, bool) { if len(column) == 0 { return 0, false @@ -171,7 +192,7 @@ func fromColumns(distances []float64, elevations []float64, coords []geodist.Coo ride := Ride{ distances: distances, elevations: elevations, - Coords: coords, + coords: coords, timestamps: timestamps, heartRates: heartRates, cadences: cadences, @@ -235,15 +256,15 @@ const CotacolAlgorithmVersion = "v1" const cotacolSegmentLengthM = 100 func Cotacol(ride Ride) float64 { - return difficultyScore(ride, 0, ride.Len()-1) + return difficultyScore(ride.distances, ride.elevations, 0, ride.Len()-1) } -func difficultyScore(r Ride, startIndex, endIndex int) float64 { +func difficultyScore(distances, elevations []float64, startIndex, endIndex int) float64 { if endIndex-startIndex < 1 { return 0 } - startDistance := r.DistanceM(startIndex) - lastDistance := r.DistanceM(endIndex) + startDistance := distances[startIndex] + lastDistance := distances[endIndex] if lastDistance <= startDistance { return 0 } @@ -251,14 +272,14 @@ func difficultyScore(r Ride, startIndex, endIndex int) float64 { i := startIndex for start := startDistance; start < lastDistance; start += cotacolSegmentLengthM { end := math.Min(start+cotacolSegmentLengthM, lastDistance) - for i < endIndex && r.DistanceM(i+1) <= start { + for i < endIndex && distances[i+1] <= start { i++ } - startElevation := interpolateElevation(r, i, start) - for i < endIndex && r.DistanceM(i+1) < end { + startElevation := interpolateElevation(distances, elevations, i, start) + for i < endIndex && distances[i+1] < end { i++ } - endElevation := interpolateElevation(r, i, end) + endElevation := interpolateElevation(distances, elevations, i, end) slope := (endElevation - startElevation) / (end - start) if slope > 0 { cotacol += (end - start) / 1000 * (slope * 100) * (slope * 100) @@ -267,14 +288,14 @@ func difficultyScore(r Ride, startIndex, endIndex int) float64 { return cotacol } -func interpolateElevation(r Ride, i int, distance float64) float64 { - if i+1 >= r.Len() { - return r.ElevationM(i) +func interpolateElevation(distances, elevations []float64, i int, distance float64) float64 { + if i+1 >= len(distances) { + return elevations[i] } - startDistance := r.DistanceM(i) - endDistance := r.DistanceM(i + 1) + startDistance := distances[i] + endDistance := distances[i+1] t := (distance - startDistance) / (endDistance - startDistance) - return r.ElevationM(i) + t*(r.ElevationM(i+1)-r.ElevationM(i)) + return elevations[i] + t*(elevations[i+1]-elevations[i]) } func (r *Ride) ClimbFromDist(startDist, endDist float64) Climb { @@ -288,10 +309,21 @@ func (r *Ride) ClimbFromDist(startDist, endDist float64) Climb { break } } - return Climb{ride: *r, rideStart: start, rideEnd: end} + return r.climbFrom(start, end) } func (r *Ride) ClimbFromIndexes(startIndex, endIndex int) Climb { kcore.Assert(startIndex >= 0 && endIndex < r.Len() && startIndex < endIndex, "invalid climb indexes") - return Climb{ride: *r, rideStart: startIndex, rideEnd: endIndex} + return r.climbFrom(startIndex, endIndex) +} + +func (r *Ride) climbFrom(start, end int) Climb { + return Climb{ + distances: r.distances, + elevations: r.elevations, + coords: r.coords, + timestamps: r.timestamps, + rideStart: start, + rideEnd: end, + } } |
