aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorMartin Kagamino Lehoux <martin@lehoux.net>2026-08-25 11:24:51 +0200
committerMartin Kagamino Lehoux <martin@lehoux.net>2026-08-25 11:24:51 +0200
commitff3cd6248f2b78dc76f1ce8cc2337907188fd401 (patch)
tree59cb08531d7a7087a0a71c2306ce73330527c8d4
parent9c2337b50a52ee95011028f6193e0c56aa1fc9d4 (diff)
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.
-rw-r--r--official_climb/official_climb_test.go18
-rw-r--r--ride/climb.go126
-rw-r--r--ride/ecs_test.go43
-rw-r--r--ride/moving_time.go18
-rw-r--r--ride/ride.go72
-rw-r--r--rideanalysis/analysis.go4
6 files changed, 187 insertions, 94 deletions
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),