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. --- official_climb/official_climb_test.go | 18 ++--- ride/climb.go | 126 +++++++++++++++++++--------------- ride/ecs_test.go | 43 ++++++++++++ ride/moving_time.go | 18 ++--- ride/ride.go | 72 +++++++++++++------ rideanalysis/analysis.go | 4 +- 6 files changed, 187 insertions(+), 94 deletions(-) create mode 100644 ride/ecs_test.go diff --git a/official_climb/official_climb_test.go b/official_climb/official_climb_test.go index a0c8d65..266581e 100644 --- a/official_climb/official_climb_test.go +++ b/official_climb/official_climb_test.go @@ -68,7 +68,7 @@ func TestMatchClimbMatchesWithoutDetectedClimb(t *testing.T) { StartCoord: parsed.Coord(1), EndCoord: parsed.Coord(2), } - matched, found := official_climb.MatchClimb(parsed.Coords, official, official_climb.DefaultMatchRadiusM) + matched, found := official_climb.MatchClimb(parsed.Coords(), official, official_climb.DefaultMatchRadiusM) require.True(t, found) assert.Equal(t, 1, matched.StartIndex) assert.Equal(t, 2, matched.EndIndex) @@ -91,7 +91,7 @@ func TestMatchClimbMatchesEndpointInsideRadius(t *testing.T) { StartCoord: geodist.Coord{Lat: 43.1101, Lon: 5.1101}, EndCoord: geodist.Coord{Lat: 43.1201, Lon: 5.1201}, } - matched, found := official_climb.MatchClimb(parsed.Coords, official, official_climb.DefaultMatchRadiusM) + matched, found := official_climb.MatchClimb(parsed.Coords(), official, official_climb.DefaultMatchRadiusM) require.True(t, found) assert.Equal(t, 1, matched.StartIndex) assert.Equal(t, 2, matched.EndIndex) @@ -114,7 +114,7 @@ func TestMatchClimbRejectsReversedEndpoints(t *testing.T) { StartCoord: parsed.Coord(2), EndCoord: parsed.Coord(1), } - _, found := official_climb.MatchClimb(parsed.Coords, official, official_climb.DefaultMatchRadiusM) + _, found := official_climb.MatchClimb(parsed.Coords(), official, official_climb.DefaultMatchRadiusM) assert.False(t, found) } @@ -135,7 +135,7 @@ func TestMatchClimbRejectsEndpointOutsideRadius(t *testing.T) { StartCoord: geodist.Coord{Lat: 43.09, Lon: 5.09}, EndCoord: parsed.Coord(2), } - _, found := official_climb.MatchClimb(parsed.Coords, official, 10) + _, found := official_climb.MatchClimb(parsed.Coords(), official, 10) assert.False(t, found) } @@ -161,14 +161,14 @@ func TestMatchClimbsMatchesTwoLaps(t *testing.T) { StartCoord: geodist.Coord{Lat: 43.10, Lon: 5.10}, EndCoord: geodist.Coord{Lat: 43.12, Lon: 5.12}, } - matches := official_climb.MatchClimbs(parsed.Coords, official, official_climb.DefaultMatchRadiusM) + matches := official_climb.MatchClimbs(parsed.Coords(), official, official_climb.DefaultMatchRadiusM) require.Len(t, matches, 2) assert.Equal(t, 0, matches[0].StartIndex) assert.Equal(t, 2, matches[0].EndIndex) assert.Equal(t, 4, matches[1].StartIndex) assert.Equal(t, 6, matches[1].EndIndex) // Single-match helper still returns first occurrence for backward compatibility. - single, found := official_climb.MatchClimb(parsed.Coords, official, official_climb.DefaultMatchRadiusM) + single, found := official_climb.MatchClimb(parsed.Coords(), official, official_climb.DefaultMatchRadiusM) require.True(t, found) assert.Equal(t, 0, single.StartIndex) } @@ -193,7 +193,7 @@ func TestMatchClimbsMatchesOnlyCompleteLaps(t *testing.T) { StartCoord: geodist.Coord{Lat: 43.10, Lon: 5.10}, EndCoord: geodist.Coord{Lat: 43.12, Lon: 5.12}, } - matches := official_climb.MatchClimbs(parsed.Coords, official, official_climb.DefaultMatchRadiusM) + matches := official_climb.MatchClimbs(parsed.Coords(), official, official_climb.DefaultMatchRadiusM) require.Len(t, matches, 1) assert.Equal(t, 3, matches[0].StartIndex) assert.Equal(t, 5, matches[0].EndIndex) @@ -216,7 +216,7 @@ func TestMatchClimbsPreservesDirectionAcrossLaps(t *testing.T) { StartCoord: geodist.Coord{Lat: 43.10, Lon: 5.10}, EndCoord: geodist.Coord{Lat: 43.12, Lon: 5.12}, } - matches := official_climb.MatchClimbs(parsed.Coords, official, official_climb.DefaultMatchRadiusM) + matches := official_climb.MatchClimbs(parsed.Coords(), official, official_climb.DefaultMatchRadiusM) assert.Empty(t, matches) } @@ -242,7 +242,7 @@ func TestMatchClimbsClustersContiguousPoints(t *testing.T) { StartCoord: geodist.Coord{Lat: 43.10, Lon: 5.10}, EndCoord: geodist.Coord{Lat: 43.12, Lon: 5.12}, } - matches := official_climb.MatchClimbs(parsed.Coords, official, official_climb.DefaultMatchRadiusM) + matches := official_climb.MatchClimbs(parsed.Coords(), official, official_climb.DefaultMatchRadiusM) require.Len(t, matches, 2) assert.Equal(t, 0, matches[0].StartIndex) assert.Equal(t, 4, matches[0].EndIndex) 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, + } } diff --git a/rideanalysis/analysis.go b/rideanalysis/analysis.go index 90be629..55f77b8 100644 --- a/rideanalysis/analysis.go +++ b/rideanalysis/analysis.go @@ -39,7 +39,7 @@ func Analyze(parsed ride.Ride, passes []mountain_pass.MountainPass, officialClim claimedClimbs := make([]AnalyzedClimb, 0) claimedRanges := make([]ride.Range, 0) for _, official := range officialClimbs { - for _, matched := range official_climb.MatchClimbs(parsed.Coords, official, matchPolicy.EndpointRadiusM) { + for _, matched := range official_climb.MatchClimbs(parsed.Coords(), official, matchPolicy.EndpointRadiusM) { claimedRanges = append(claimedRanges, ride.Range{Start: matched.StartIndex, End: matched.EndIndex}) claimedClimbs = append(claimedClimbs, analyzedClimb(parsed, parsed.ClimbFromIndexes(matched.StartIndex, matched.EndIndex), official.Name, official.ID)) } @@ -72,7 +72,7 @@ func analyzedClimb(parsed ride.Ride, segment ride.Climb, name string, officialID Name: name, Category: ride.CategoryOf(cotacol).String(), DistanceM: segment.EndDistanceM() - segment.StartDistanceM(), - SlopePercent: ride.Slope(parsed, segment.StartIndex(), segment.EndIndex()) * 100, + SlopePercent: ride.Slope(parsed.Distances(), parsed.Elevations(), segment.StartIndex(), segment.EndIndex()) * 100, Cotacol: cotacol, OfficialClimbID: officialID, OfficialName: officialClimbNameForID(name, officialID), -- cgit v1.3