aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--README.md2
-rw-r--r--climb/climb.go87
-rw-r--r--climb/cotacol.go48
-rw-r--r--climb/detection.go175
-rw-r--r--climb/detection_test.go84
-rw-r--r--climb/difficulty_test.go72
-rw-r--r--climb/route.go9
-rw-r--r--core/core.go12
-rw-r--r--internal/ridetest/ride.go298
-rw-r--r--internal/ridetest/ride_test.go48
-rw-r--r--internal/routetest/routetest.go331
-rw-r--r--mountain_pass/detection.go33
-rw-r--r--mountain_pass/detection_test.go24
-rw-r--r--official_climb/official_climb_test.go28
-rw-r--r--ride/climb.go329
-rw-r--r--ride/climb_test.go135
-rw-r--r--ride/detection_example_test.go87
-rw-r--r--ride/difficulty_example_test.go25
-rw-r--r--ride/difficulty_test.go87
-rw-r--r--ride/ecs_test.go43
-rw-r--r--ride/moving_time.go2
-rw-r--r--ride/parser_test.go2
-rw-r--r--ride/ride.go109
-rw-r--r--ride/store.go7
-rw-r--r--ride/store_test.go11
-rw-r--r--rideanalysis/analysis.go49
-rw-r--r--web/ride-filters.go3
-rw-r--r--web/ride-filters_test.go3
-rw-r--r--web/rides.go7
-rw-r--r--web/server_test.go13
-rw-r--r--web/views.go24
31 files changed, 1100 insertions, 1087 deletions
diff --git a/README.md b/README.md
index 4d9a1f8..1ae3a8b 100644
--- a/README.md
+++ b/README.md
@@ -154,7 +154,7 @@ Add `github.com/grafana/pyroscope-go` and call `pyroscope.Start` with `ServerAdd
## TODO
-- use interval
+- pyroscope
- screaming archi + hexagon
- Compute estimated power, speed
- For a ride, scatter, one point per 100m segment. Slope vs speed
diff --git a/climb/climb.go b/climb/climb.go
new file mode 100644
index 0000000..2d764dd
--- /dev/null
+++ b/climb/climb.go
@@ -0,0 +1,87 @@
+package climb
+
+import (
+ "fmt"
+ "math"
+)
+
+const (
+ ClimbDistanceMinimum = 500
+ ClimbSlopeMinimum = 0.035
+ detectionMinimumScore = 35
+)
+
+type Category int
+
+const (
+ CategoryNo Category = iota
+ CategoryFour
+ CategoryThree
+ CategoryTwo
+ CategoryOne
+ CategoryHC
+)
+
+type categoryInfo struct {
+ label string
+ minScore float64
+}
+
+var categories = map[Category]categoryInfo{
+ CategoryNo: {label: "NO", minScore: math.Inf(-1)},
+ CategoryFour: {label: "Cat 4", minScore: 35},
+ CategoryThree: {label: "Cat 3", minScore: 80},
+ CategoryTwo: {label: "Cat 2", minScore: 180},
+ CategoryOne: {label: "Cat 1", minScore: 250},
+ CategoryHC: {label: "HC", minScore: 600},
+}
+
+func (c Category) String() string {
+ return categories[c].label
+}
+
+func CategoryOf(cotacol float64) Category {
+ best := CategoryNo
+ bestMin := math.Inf(-1)
+ found := false
+ for category, info := range categories {
+ if cotacol >= info.minScore && (!found || info.minScore > bestMin) {
+ best = category
+ bestMin = info.minScore
+ found = true
+ }
+ }
+ if !found {
+ // NaN reaches no floor; preserve the original switch default (HC).
+ return CategoryHC
+ }
+ return best
+}
+
+type Climb struct {
+ Route
+}
+
+func (climb Climb) String() string {
+ cotacol := climb.DifficultyScore()
+ return fmt.Sprintf("%.1fkm at %.1f%% (%d pts - %s)", climb.Distances[len(climb.Distances)-1]/1000, Slope(climb.Distances, climb.Elevations)*100, int(cotacol), CategoryOf(cotacol))
+}
+
+func (climb Climb) DifficultyScore() float64 {
+ return Cotacol(climb.Distances, climb.Elevations)
+}
+
+func (climb Climb) Top() (int, float64) {
+ top := 0
+ for i := 1; i < len(climb.Elevations); i++ {
+ if climb.Elevations[i] > climb.Elevations[top] {
+ top = i
+ }
+ }
+ return top, climb.Elevations[top]
+}
+
+func Slope(distances, elevations []float64) float64 {
+ end := len(distances) - 1
+ return (elevations[end] - elevations[0]) / (distances[end] - distances[0])
+}
diff --git a/climb/cotacol.go b/climb/cotacol.go
new file mode 100644
index 0000000..85b096b
--- /dev/null
+++ b/climb/cotacol.go
@@ -0,0 +1,48 @@
+package climb
+
+import "math"
+
+const (
+ CotacolAlgorithmVersion = "v1"
+ cotacolSegmentLengthM = 100
+)
+
+func Cotacol(distances, elevations []float64) float64 {
+ startIndex, endIndex := 0, len(distances)-1
+ if endIndex-startIndex < 1 {
+ return 0
+ }
+ startDistance := distances[startIndex]
+ lastDistance := distances[endIndex]
+ if lastDistance <= startDistance {
+ return 0
+ }
+ cotacol := 0.0
+ i := startIndex
+ for start := startDistance; start < lastDistance; start += cotacolSegmentLengthM {
+ end := math.Min(start+cotacolSegmentLengthM, lastDistance)
+ for i < endIndex && distances[i+1] <= start {
+ i++
+ }
+ startElevation := interpolateElevation(distances, elevations, i, start)
+ for i < endIndex && distances[i+1] < end {
+ i++
+ }
+ endElevation := interpolateElevation(distances, elevations, i, end)
+ slope := (endElevation - startElevation) / (end - start)
+ if slope > 0 {
+ cotacol += (end - start) / 1000 * (slope * 100) * (slope * 100)
+ }
+ }
+ return cotacol
+}
+
+func interpolateElevation(distances, elevations []float64, i int, distance float64) float64 {
+ if i+1 >= len(distances) {
+ return elevations[i]
+ }
+ startDistance := distances[i]
+ endDistance := distances[i+1]
+ t := (distance - startDistance) / (endDistance - startDistance)
+ return elevations[i] + t*(elevations[i+1]-elevations[i])
+}
diff --git a/climb/detection.go b/climb/detection.go
new file mode 100644
index 0000000..1efe1c5
--- /dev/null
+++ b/climb/detection.go
@@ -0,0 +1,175 @@
+package climb
+
+import (
+ "log/slog"
+ "math"
+ "slices"
+
+ "github.com/martinlehoux/biking_home/core"
+ "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 []core.Interval, minSlope float64) []DetectedClimb {
+ distances, elevations, coords := route.Distances, route.Elevations, route.Coords
+ bounds := make([]core.Interval, 0)
+ for _, region := range freeRegions(0, len(distances)-1, claimed) {
+ bounds = append(bounds, climbsBetween(distances, elevations, core.NewInterval(region.Start, region.End), minSlope)...)
+ }
+ slices.SortFunc(bounds, func(a, b core.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 []core.Interval) []core.Interval {
+ valid := make([]core.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 core.Interval) int { return a.Start - b.Start })
+ regions := make([]core.Interval, 0, len(valid)+1)
+ current := rStart
+ for _, claim := range valid {
+ if claim.End < current {
+ continue
+ }
+ if claim.Start > current {
+ regions = append(regions, core.NewInterval(current, claim.Start-1))
+ }
+ if claim.End+1 > current {
+ current = claim.End + 1
+ }
+ }
+ if current <= rEnd {
+ regions = append(regions, core.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 core.Interval, minSlope float64) []core.Interval {
+ start, end := bounds.Start, bounds.End
+ climbs := []core.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, core.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, core.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, core.NewInterval(climbStart, climbEnd))
+ }
+ climbs = append(climbs, climbsBetween(distances, elevations, core.NewInterval(start, climbStart), minSlope)...)
+ climbs = append(climbs, climbsBetween(distances, elevations, core.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) (core.Interval, bool) {
+ end := len(distances) - 1
+ kcore.Assert(end > 0, "empty points")
+
+ bestDetectionScore := climbDetectionScore(distances, elevations)
+ best := core.NewInterval(0, end)
+ for i := 0; i < end; i++ {
+ 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 = core.NewInterval(-1, -1)
+ for i := 0; i < end; i++ {
+ 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 core.Interval{}, false
+ }
+ return best, true
+}
diff --git a/climb/detection_test.go b/climb/detection_test.go
new file mode 100644
index 0000000..3c6a33b
--- /dev/null
+++ b/climb/detection_test.go
@@ -0,0 +1,84 @@
+package climb_test
+
+import (
+ "fmt"
+ "testing"
+
+ "github.com/martinlehoux/biking_home/climb"
+ "github.com/martinlehoux/biking_home/core"
+ "github.com/martinlehoux/biking_home/internal/routetest"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+// describe renders a detected climb as the ride-style
+// "start-end: length at slope (pts - cat)" string, so positions are asserted.
+func describe(route climb.Route, detected climb.DetectedClimb) string {
+ seg := detected.Climb
+ startKm := route.Distances[detected.Start] / 1000
+ endKm := route.Distances[detected.Start+len(seg.Distances)-1] / 1000
+ lengthKm := (seg.Distances[len(seg.Distances)-1] - seg.Distances[0]) / 1000
+ slopePercent := climb.Slope(seg.Distances, seg.Elevations) * 100
+ pts := int(seg.DifficultyScore())
+ return fmt.Sprintf("%.1fkm-%.1fkm: %.1fkm at %.1f%% (%d pts - %s)", startKm, endKm, lengthKm, slopePercent, pts, climb.CategoryOf(seg.DifficultyScore()))
+}
+
+func TestClimbThenFalseFlat(t *testing.T) {
+ route := routetest.MustRoute("2km at 7% | 10km at 1%")
+ detected := climb.Detect(route, nil, climb.ClimbSlopeMinimum)
+
+ assert.Len(t, detected, 1)
+ assert.Equal(t, "0.0km-2.0km: 2.0km at 7.0% (98 pts - Cat 3)", describe(route, detected[0]))
+}
+
+func TestSmallClimbWithDescentInsideFalseFlat(t *testing.T) {
+ route := routetest.MustRoute("10km at 1% | 2km at 7% | 1km at -7% | 10km at 1%")
+ detected := climb.Detect(route, nil, climb.ClimbSlopeMinimum)
+
+ assert.Len(t, detected, 1)
+ assert.Equal(t, "10.0km-12.0km: 2.0km at 7.0% (98 pts - Cat 3)", describe(route, detected[0]))
+}
+
+func TestDetectClimbsSkipsMiddleClaim(t *testing.T) {
+ // A climb, a false flat giving a saddle, then a climb again. Claiming the
+ // saddle's descent base should leave both climbs detected separately.
+ route := routetest.MustRoute("2km at 7% | 1km at -7% | 2km at 7%")
+ detected := climb.Detect(route, []core.Interval{core.NewInterval(20, 20)}, climb.ClimbSlopeMinimum)
+
+ // No free region falls under the min distance except the two climbs.
+ assert.Len(t, detected, 2)
+ assert.Equal(t, "0.0km-2.0km: 2.0km at 7.0% (98 pts - Cat 3)", describe(route, detected[0]))
+ assert.Equal(t, "3.0km-5.0km: 2.0km at 7.0% (98 pts - Cat 3)", describe(route, detected[1]))
+}
+
+func TestDetectClimbsOverlappingClaimDropsClimb(t *testing.T) {
+ // Claiming the ascent region entirely swallows the climb.
+ route := routetest.MustRoute("2km at 7% | 20km at 0%")
+ detected := climb.Detect(route, []core.Interval{core.NewInterval(0, 40)}, climb.ClimbSlopeMinimum)
+ assert.Empty(t, detected)
+}
+
+func TestDetectClimbsIgnoresOutOfRangeClaim(t *testing.T) {
+ route := routetest.MustRoute("2km at 7% | 20km at 0%")
+ detected := climb.Detect(route, []core.Interval{core.NewInterval(-5, -1)}, climb.ClimbSlopeMinimum)
+ require.Len(t, detected, 1)
+ assert.Equal(t, "0.0km-2.0km: 2.0km at 7.0% (98 pts - Cat 3)", describe(route, detected[0]))
+}
+
+func TestDetectClimbsWithUnsortedClaimsDropsOverlappingClimb(t *testing.T) {
+ // Reproduces ride 6 Chemin des Lauriers bug: Detect must not depend on
+ // caller-provided order. Two official claims out of ID order (Bastides
+ // 2140-2627 before Lauriers 1215-1588) caused freeRegions to skip the
+ // earlier claim and leave the overlapping Cat 4 1352-1601 visible.
+ // Minimal reproduction: two climbs 0-2km and 3-5km separated by a descent.
+ route := routetest.MustRoute("2km at 7% | 1km at -7% | 2km at 7%")
+ // Both climbs claimed, but in reverse order (mimics ORDER BY id returning 30-50 before 0-20).
+ // Sorted input correctly yields 0 detected climbs.
+ sorted := []core.Interval{core.NewInterval(0, 20), core.NewInterval(30, 50)}
+ assert.Empty(t, climb.Detect(route, sorted, climb.ClimbSlopeMinimum))
+
+ unsorted := []core.Interval{core.NewInterval(30, 50), core.NewInterval(0, 20)}
+ detected := climb.Detect(route, unsorted, climb.ClimbSlopeMinimum)
+ // Before fix this fails: freeRegions skips {0,20} because 20 < 30 and returns 1 climb.
+ assert.Empty(t, detected, "unsorted claimed ranges must still suppress overlapping climbs — expected 0, got %v", detected)
+}
diff --git a/climb/difficulty_test.go b/climb/difficulty_test.go
new file mode 100644
index 0000000..92c0a74
--- /dev/null
+++ b/climb/difficulty_test.go
@@ -0,0 +1,72 @@
+package climb_test
+
+import (
+ "math"
+ "testing"
+
+ "github.com/martinlehoux/biking_home/climb"
+ "github.com/martinlehoux/biking_home/internal/routetest"
+ "github.com/stretchr/testify/assert"
+)
+
+func TestCotacolConstantClimb(t *testing.T) {
+ route := routetest.MustRoute("2km at 7%")
+ assert.InDelta(t, 98.0, climb.Cotacol(route.Distances, route.Elevations), 1e-9)
+}
+
+func TestCotacolIgnoresDescent(t *testing.T) {
+ route := routetest.MustRoute("2km at 7% | 2km at -7%")
+ assert.InDelta(t, 98.0, climb.Cotacol(route.Distances, route.Elevations), 1e-9)
+}
+
+func TestCotacolFlatIsZero(t *testing.T) {
+ route := routetest.MustRoute("5km at 0%")
+ assert.Zero(t, climb.Cotacol(route.Distances, route.Elevations))
+}
+
+func TestCotacolSteepShortCountsMore(t *testing.T) {
+ steep := routetest.MustRoute("0.5km at 14%")
+ long := routetest.MustRoute("1km at 7%")
+ assert.InDelta(t, 98.0, climb.Cotacol(steep.Distances, steep.Elevations), 1e-9)
+ assert.InDelta(t, 49.0, climb.Cotacol(long.Distances, long.Elevations), 1e-9)
+}
+
+func TestCotacolPrecisionInvariant(t *testing.T) {
+ coarse := routetest.MustRoute("3km at 5%")
+ fine := routetest.MustRoute("3km at 5%", routetest.Sampling(50))
+ assert.InDelta(t, climb.Cotacol(coarse.Distances, coarse.Elevations), climb.Cotacol(fine.Distances, fine.Elevations), 1e-9)
+}
+
+func TestCotacolClimbNotAtStart(t *testing.T) {
+ route := routetest.MustRoute("1km at 0% | 2km at 7%")
+ detected := climb.Detect(route, nil, climb.ClimbSlopeMinimum)
+ assert.Len(t, detected, 1)
+ assert.InDelta(t, 98.0, detected[0].Climb.DifficultyScore(), 1e-9)
+}
+
+func TestCategoryOfBoundaries(t *testing.T) {
+ cases := []struct {
+ score float64
+ want climb.Category
+ }{
+ {-5, climb.CategoryNo},
+ {30, climb.CategoryNo},
+ {35, climb.CategoryFour}, // 35 is the first floor, not below it
+ {79, climb.CategoryFour},
+ {80, climb.CategoryThree},
+ {179, climb.CategoryThree},
+ {180, climb.CategoryTwo},
+ {249, climb.CategoryTwo},
+ {250, climb.CategoryOne},
+ {599, climb.CategoryOne},
+ {600, climb.CategoryHC},
+ {700, climb.CategoryHC},
+ {math.NaN(), climb.CategoryHC}, // reaches no floor, like the original switch default
+ }
+
+ for _, c := range cases {
+ assert.Equal(t, c.want, climb.CategoryOf(c.score), "score %v", c.score)
+ }
+ assert.Equal(t, "Cat 1", climb.CategoryOne.String())
+ assert.Equal(t, "NO", climb.CategoryNo.String())
+}
diff --git a/climb/route.go b/climb/route.go
new file mode 100644
index 0000000..80a9a43
--- /dev/null
+++ b/climb/route.go
@@ -0,0 +1,9 @@
+package climb
+
+import "github.com/jftuga/geodist"
+
+type Route struct {
+ Distances []float64
+ Elevations []float64
+ Coords []geodist.Coord
+}
diff --git a/core/core.go b/core/core.go
new file mode 100644
index 0000000..e2923c1
--- /dev/null
+++ b/core/core.go
@@ -0,0 +1,12 @@
+package core
+
+type Interval struct {
+ Start, End int
+}
+
+func NewInterval(start, end int) Interval {
+ return Interval{
+ Start: start,
+ End: end,
+ }
+}
diff --git a/internal/ridetest/ride.go b/internal/ridetest/ride.go
index e74ce49..50a84cd 100644
--- a/internal/ridetest/ride.go
+++ b/internal/ridetest/ride.go
@@ -1,135 +1,36 @@
-// Package ridetest builds ride.Ride test fixtures from a compact string DSL.
+// Package ridetest builds ride.Ride test fixtures from a compact string DSL. It
+// reuses routetest to sample the route geometry and layers ride timestamps and
+// telemetry on top.
//
-// Grammar: sections separated by "|", each "<distance>km at <slope>%" with
-// optional per-section metrics "hr=150 cad=80 w=200":
-//
-// ridetest.MustRide("2km at 7% | 1km at -7%")
-//
-// Options adjust the generated columns: Sampling (step in meters, default
-// 100), StartCoord (first point; later points progress northeast at the
-// Heading rate, default 0.01° of latitude and longitude per kilometer),
-// StartTime (default the zero time, one second between points) and
-// StartElevation (default 0).
+// Grammar: see routetest.
package ridetest
import (
- "fmt"
- "strconv"
- "strings"
- "time"
-
- "github.com/jftuga/geodist"
+ "github.com/martinlehoux/biking_home/internal/routetest"
"github.com/martinlehoux/biking_home/ride"
)
-const defaultSamplingM = 100
-
-// headingPerKm is the latitude and longitude offset applied to each point per
-// kilometer of ride, giving StartCoord rides a fixed northeast direction.
-const headingPerKm = 0.01
-
-type section struct {
- distanceM float64
- slope float64
- heartRate *float64
- cadence *float64
- power *float64
-}
-
-type point struct {
- distanceM float64
- elevationM float64
- coord geodist.Coord
- timestamp time.Time
- section section
-}
-
-type options struct {
- samplingM float64
- startCoord *geodist.Coord
- headingLat float64
- headingLon float64
- startTime time.Time
- startElevM float64
-}
-
-// Option configures the ride generated by Ride.
-type Option func(*options)
-
-// Sampling sets the distance between consecutive points in meters
-// (default 100).
-func Sampling(m float64) Option {
- return func(o *options) { o.samplingM = m }
-}
-
-// StartCoord sets the coordinate of the first point. Each following point
-// progresses northeast at the rate set by Heading, default 0.01° of latitude
-// and longitude per kilometer.
-func StartCoord(lat, lon float64) Option {
- return func(o *options) {
- o.startCoord = &geodist.Coord{Lat: lat, Lon: lon}
- if o.headingLat == 0 {
- o.headingLat = headingPerKm
- }
- if o.headingLon == 0 {
- o.headingLon = headingPerKm
- }
- }
-}
+// Option is re-exported from routetest so existing ridetest callers keep
+// working unchanged.
+type Option = routetest.Option
-// Heading sets the latitude and longitude offset applied to each point per
-// kilometer of ride, in degrees (default 0.01 northeast when StartCoord is
-// set). Only meaningful together with StartCoord.
-func Heading(latPerKm, lonPerKm float64) Option {
- return func(o *options) {
- o.headingLat = latPerKm
- o.headingLon = lonPerKm
- }
-}
-
-// StartTime sets the timestamp of the first point; each following point is
-// one second later (default: the zero time).
-func StartTime(t time.Time) Option {
- return func(o *options) { o.startTime = t }
-}
-
-// StartElevation sets the elevation of the first point, in meters (default 0).
-func StartElevation(m float64) Option {
- return func(o *options) { o.startElevM = m }
-}
+var (
+ Sampling = routetest.Sampling
+ StartCoord = routetest.StartCoord
+ Heading = routetest.Heading
+ StartTime = routetest.StartTime
+ StartElevation = routetest.StartElevation
+)
// Ride parses a ride description into a ride.Ride.
//
// Example: "2km at 7% | 1km at -7%".
func Ride(dsl string, opts ...Option) (ride.Ride, error) {
- o := options{samplingM: defaultSamplingM, headingLat: headingPerKm, headingLon: headingPerKm}
- for _, opt := range opts {
- opt(&o)
- }
- if o.samplingM <= 0 {
- return ride.Ride{}, fmt.Errorf("ridetest: sampling must be positive, got %v", o.samplingM)
- }
- sections, err := parseSections(dsl)
+ built, err := routetest.Build(dsl, opts...)
if err != nil {
return ride.Ride{}, err
}
- if len(sections) == 0 {
- return ride.Ride{}, fmt.Errorf("ridetest: empty ride description")
- }
- for _, metric := range []struct {
- name string
- get func(section) *float64
- }{
- {"hr", func(s section) *float64 { return s.heartRate }},
- {"cad", func(s section) *float64 { return s.cadence }},
- {"w", func(s section) *float64 { return s.power }},
- } {
- present, complete := metricComplete(sections, metric.get)
- if present && !complete {
- return ride.Ride{}, fmt.Errorf("ridetest: %s= must be set on every section or none", metric.name)
- }
- }
- return buildRide(sections, o), nil
+ return ride.FromColumnsWithMetrics(built.Distances, built.Elevations, built.Coords, built.Timestamps, built.HeartRates, built.Cadences, built.Powers), nil
}
// MustRide is like Ride but panics on a malformed description; use it inside
@@ -141,168 +42,3 @@ func MustRide(dsl string, opts ...Option) ride.Ride {
}
return ride
}
-
-func parseSections(dsl string) ([]section, error) {
- rawSections := strings.Split(dsl, "|")
- sections := make([]section, 0, len(rawSections))
- for _, raw := range rawSections {
- raw = strings.TrimSpace(raw)
- if raw == "" {
- return nil, fmt.Errorf("ridetest: empty section in %q", dsl)
- }
- sec, err := parseSection(raw)
- if err != nil {
- return nil, err
- }
- sections = append(sections, sec)
- }
- return sections, nil
-}
-
-// parseSection parses a single section like "2km at 7% hr=150 cad=80 w=200".
-func parseSection(raw string) (section, error) {
- fields := strings.Fields(raw)
- if len(fields) < 3 {
- return section{}, fmt.Errorf("ridetest: section %q must look like \"2km at 7%%\"", raw)
- }
- if fields[1] != "at" {
- return section{}, fmt.Errorf("ridetest: section %q: expected \"at\" between distance and slope, got %q", raw, fields[1])
- }
- distanceKm, err := parseUnit(fields[0], "km")
- if err != nil {
- return section{}, fmt.Errorf("ridetest: section %q: %w", raw, err)
- }
- slopePercent, err := parseUnit(fields[2], "%")
- if err != nil {
- return section{}, fmt.Errorf("ridetest: section %q: %w", raw, err)
- }
- if distanceKm <= 0 {
- return section{}, fmt.Errorf("ridetest: section %q: distance must be positive", raw)
- }
- sec := section{
- distanceM: distanceKm * 1000,
- slope: slopePercent / 100,
- }
- for _, token := range fields[3:] {
- var value *float64
- switch {
- case strings.HasPrefix(token, "hr="):
- value, err = parseMetric(token, "hr")
- sec.heartRate = value
- case strings.HasPrefix(token, "cad="):
- value, err = parseMetric(token, "cad")
- sec.cadence = value
- case strings.HasPrefix(token, "w="):
- value, err = parseMetric(token, "w")
- sec.power = value
- default:
- return section{}, fmt.Errorf("ridetest: section %q: unknown metric %q (want hr=, cad= or w=)", raw, token)
- }
- if err != nil {
- return section{}, fmt.Errorf("ridetest: section %q: %w", raw, err)
- }
- }
- return sec, nil
-}
-
-func parseUnit(token, unit string) (float64, error) {
- if !strings.HasSuffix(token, unit) {
- return 0, fmt.Errorf("expected a value in %s, got %q", unit, token)
- }
- return strconv.ParseFloat(strings.TrimSuffix(token, unit), 64)
-}
-
-func parseMetric(token, name string) (*float64, error) {
- value, err := strconv.ParseFloat(strings.TrimPrefix(token, name+"="), 64)
- if err != nil {
- return nil, fmt.Errorf("invalid %s value %q", name, token)
- }
- return &value, nil
-}
-
-// metricComplete reports whether a metric column is present (any section sets
-// it) and complete (every section sets it).
-func metricComplete(sections []section, get func(section) *float64) (present, complete bool) {
- set := 0
- for _, sec := range sections {
- if get(sec) != nil {
- set++
- }
- }
- return set > 0, set == len(sections)
-}
-
-func buildRide(sections []section, o options) ride.Ride {
- points := buildPoints(sections, o)
- distances := make([]float64, len(points))
- elevations := make([]float64, len(points))
- coords := make([]geodist.Coord, len(points))
- timestamps := make([]time.Time, len(points))
- var heartRates, cadences, powers []float64
- if sections[0].heartRate != nil {
- heartRates = make([]float64, len(points))
- }
- if sections[0].cadence != nil {
- cadences = make([]float64, len(points))
- }
- if sections[0].power != nil {
- powers = make([]float64, len(points))
- }
- for i, p := range points {
- distances[i] = p.distanceM
- elevations[i] = p.elevationM
- coords[i] = p.coord
- timestamps[i] = p.timestamp
- if heartRates != nil {
- heartRates[i] = *p.section.heartRate
- }
- if cadences != nil {
- cadences[i] = *p.section.cadence
- }
- if powers != nil {
- powers[i] = *p.section.power
- }
- }
- return ride.FromColumnsWithMetrics(distances, elevations, coords, timestamps, heartRates, cadences, powers)
-}
-
-func buildPoints(sections []section, o options) []point {
- lat, lon := 0.0, 0.0
- if o.startCoord != nil {
- lat, lon = o.startCoord.Lat, o.startCoord.Lon
- }
- // Coordinates derive from the total distance so that exact multiples of
- // the per-kilometer heading stay exact; a running accumulation drifts
- // after a few points.
- coord := func(distanceM float64) geodist.Coord {
- return geodist.Coord{Lat: lat + o.headingLat*(distanceM/1000), Lon: lon + o.headingLon*(distanceM/1000)}
- }
- points := make([]point, 0, len(sections)+1)
- elevation := o.startElevM
- timestamp := o.startTime
- points = append(points, point{
- distanceM: 0,
- elevationM: elevation,
- coord: coord(0),
- timestamp: timestamp,
- section: sections[0],
- })
- distance := 0.0
- for _, sec := range sections {
- curSecDist := 0.0
- for curSecDist < sec.distanceM {
- curSecDist += o.samplingM
- elevation += o.samplingM * sec.slope
- timestamp = timestamp.Add(time.Second)
- points = append(points, point{
- distanceM: distance + curSecDist,
- elevationM: elevation,
- coord: coord(distance + curSecDist),
- timestamp: timestamp,
- section: sec,
- })
- }
- distance += curSecDist
- }
- return points
-}
diff --git a/internal/ridetest/ride_test.go b/internal/ridetest/ride_test.go
index 65483f2..46bd632 100644
--- a/internal/ridetest/ride_test.go
+++ b/internal/ridetest/ride_test.go
@@ -12,30 +12,30 @@ func TestRideSingleSection(t *testing.T) {
r, err := Ride("1km at 10%")
require.NoError(t, err)
- assert.Len(t, r.Distances(), 11)
- assert.Equal(t, 0.0, r.Distances()[0])
- assert.Equal(t, 1000.0, r.Distances()[10])
- assert.Equal(t, 0.0, r.Elevations()[0])
- assert.Equal(t, 100.0, r.Elevations()[10])
+ assert.Len(t, r.Distances, 11)
+ assert.Equal(t, 0.0, r.Distances[0])
+ assert.Equal(t, 1000.0, r.Distances[10])
+ assert.Equal(t, 0.0, r.Elevations[0])
+ assert.Equal(t, 100.0, r.Elevations[10])
}
func TestRideSectionsWithDescentAndFlat(t *testing.T) {
r, err := Ride("0.5km at 10% | 0.5km at -10%")
require.NoError(t, err)
- assert.Len(t, r.Distances(), 11)
- assert.Equal(t, 1000.0, r.Distances()[10])
- assert.Equal(t, 50.0, r.Elevations()[5]) // top of the first section
- assert.Equal(t, 0.0, r.Elevations()[10]) // back to start elevation
+ assert.Len(t, r.Distances, 11)
+ assert.Equal(t, 1000.0, r.Distances[10])
+ assert.Equal(t, 50.0, r.Elevations[5]) // top of the first section
+ assert.Equal(t, 0.0, r.Elevations[10]) // back to start elevation
}
func TestRideSamplingOption(t *testing.T) {
r, err := Ride("1km at 10%", Sampling(50))
require.NoError(t, err)
- assert.Len(t, r.Distances(), 21)
- assert.Equal(t, 1000.0, r.Distances()[20])
- assert.Equal(t, 5.0, r.Elevations()[1])
+ assert.Len(t, r.Distances, 21)
+ assert.Equal(t, 1000.0, r.Distances[20])
+ assert.Equal(t, 5.0, r.Elevations[1])
}
func TestRideStartCoordHeading(t *testing.T) {
@@ -44,13 +44,13 @@ func TestRideStartCoordHeading(t *testing.T) {
// Coordinates advance northeast by 0.01° per km; float64 literals like
// 43.11 drift in the last bits, so compare with a small tolerance.
- require.Len(t, r.Coords(), 3)
- assert.InDelta(t, 43.10, r.Coords()[0].Lat, 1e-9)
- assert.InDelta(t, 5.10, r.Coords()[0].Lon, 1e-9)
- assert.InDelta(t, 43.11, r.Coords()[1].Lat, 1e-9)
- assert.InDelta(t, 5.11, r.Coords()[1].Lon, 1e-9)
- assert.InDelta(t, 43.12, r.Coords()[2].Lat, 1e-9)
- assert.InDelta(t, 5.12, r.Coords()[2].Lon, 1e-9)
+ require.Len(t, r.Coords, 3)
+ assert.InDelta(t, 43.10, r.Coords[0].Lat, 1e-9)
+ assert.InDelta(t, 5.10, r.Coords[0].Lon, 1e-9)
+ assert.InDelta(t, 43.11, r.Coords[1].Lat, 1e-9)
+ assert.InDelta(t, 5.11, r.Coords[1].Lon, 1e-9)
+ assert.InDelta(t, 43.12, r.Coords[2].Lat, 1e-9)
+ assert.InDelta(t, 5.12, r.Coords[2].Lon, 1e-9)
}
func TestRideHeadingOption(t *testing.T) {
@@ -59,9 +59,9 @@ func TestRideHeadingOption(t *testing.T) {
// A single kilometer at 0.1°/km ends at (43.1, 5.1), matching rides whose
// points are spaced 0.1° apart over a kilometer.
- require.Len(t, r.Coords(), 2)
- assert.InDelta(t, 43.1, r.Coords()[1].Lat, 1e-9)
- assert.InDelta(t, 5.1, r.Coords()[1].Lon, 1e-9)
+ require.Len(t, r.Coords, 2)
+ assert.InDelta(t, 43.1, r.Coords[1].Lat, 1e-9)
+ assert.InDelta(t, 5.1, r.Coords[1].Lon, 1e-9)
}
func TestRideStartTime(t *testing.T) {
@@ -79,8 +79,8 @@ func TestRideStartElevation(t *testing.T) {
r, err := Ride("1km at 0%", StartElevation(100))
require.NoError(t, err)
- assert.Equal(t, 100.0, r.Elevations()[0])
- assert.Equal(t, 100.0, r.Elevations()[10])
+ assert.Equal(t, 100.0, r.Elevations[0])
+ assert.Equal(t, 100.0, r.Elevations[10])
}
func TestRideMetricsCompleteColumns(t *testing.T) {
diff --git a/internal/routetest/routetest.go b/internal/routetest/routetest.go
new file mode 100644
index 0000000..a116608
--- /dev/null
+++ b/internal/routetest/routetest.go
@@ -0,0 +1,331 @@
+// Package routetest builds climb.Route test fixtures from a compact string DSL
+// describing climbs and descents. ridetest reuses it and layers ride timing and
+// telemetry on top, so climb tests do not depend on the ride package.
+//
+// Grammar: sections separated by "|", each "<distance>km at <slope>%" with
+// optional per-section metrics "hr=150 cad=80 w=200":
+//
+// routetest.MustRoute("2km at 7% | 1km at -7%")
+//
+// Options adjust the generated columns: Sampling (step in meters, default
+// 100), StartCoord (first point; later points progress northeast at the
+// Heading rate, default 0.01° of latitude and longitude per kilometer),
+// StartTime (default the zero time, one second between points) and
+// StartElevation (default 0).
+package routetest
+
+import (
+ "fmt"
+ "strconv"
+ "strings"
+ "time"
+
+ "github.com/jftuga/geodist"
+ "github.com/martinlehoux/biking_home/climb"
+)
+
+const defaultSamplingM = 100
+
+// headingPerKm is the latitude and longitude offset applied to each point per
+// kilometer of ride, giving StartCoord rides a fixed northeast direction.
+const headingPerKm = 0.01
+
+type section struct {
+ distanceM float64
+ slope float64
+ heartRate *float64
+ cadence *float64
+ power *float64
+}
+
+type point struct {
+ distanceM float64
+ elevationM float64
+ coord geodist.Coord
+ timestamp time.Time
+ section section
+}
+
+type options struct {
+ samplingM float64
+ startCoord *geodist.Coord
+ headingLat float64
+ headingLon float64
+ startTime time.Time
+ startElevM float64
+}
+
+// Option configures the route generated by Build.
+type Option func(*options)
+
+// Sampling sets the distance between consecutive points in meters
+// (default 100).
+func Sampling(m float64) Option {
+ return func(o *options) { o.samplingM = m }
+}
+
+// StartCoord sets the coordinate of the first point. Each following point
+// progresses northeast at the rate set by Heading, default 0.01° of latitude
+// and longitude per kilometer.
+func StartCoord(lat, lon float64) Option {
+ return func(o *options) {
+ o.startCoord = &geodist.Coord{Lat: lat, Lon: lon}
+ if o.headingLat == 0 {
+ o.headingLat = headingPerKm
+ }
+ if o.headingLon == 0 {
+ o.headingLon = headingPerKm
+ }
+ }
+}
+
+// Heading sets the latitude and longitude offset applied to each point per
+// kilometer of ride, in degrees (default 0.01 northeast when StartCoord is
+// set). Only meaningful together with StartCoord.
+func Heading(latPerKm, lonPerKm float64) Option {
+ return func(o *options) {
+ o.headingLat = latPerKm
+ o.headingLon = lonPerKm
+ }
+}
+
+// StartTime sets the timestamp of the first point; each following point is
+// one second later (default: the zero time).
+func StartTime(t time.Time) Option {
+ return func(o *options) { o.startTime = t }
+}
+
+// StartElevation sets the elevation of the first point, in meters (default 0).
+func StartElevation(m float64) Option {
+ return func(o *options) { o.startElevM = m }
+}
+
+// Built is the fully sampled output of a description: the geometry columns,
+// timestamps, and, when every section provides them, the metric columns.
+type Built struct {
+ Distances []float64
+ Elevations []float64
+ Coords []geodist.Coord
+ Timestamps []time.Time
+ HeartRates []float64
+ Cadences []float64
+ Powers []float64
+}
+
+// Build parses a description into sampled columns. Metrics (heart rate,
+// cadence, power) are non-nil only when every section sets them.
+func Build(dsl string, opts ...Option) (Built, error) {
+ o := options{samplingM: defaultSamplingM, headingLat: headingPerKm, headingLon: headingPerKm}
+ for _, opt := range opts {
+ opt(&o)
+ }
+ if o.samplingM <= 0 {
+ return Built{}, fmt.Errorf("routetest: sampling must be positive, got %v", o.samplingM)
+ }
+ sections, err := parseSections(dsl)
+ if err != nil {
+ return Built{}, err
+ }
+ if len(sections) == 0 {
+ return Built{}, fmt.Errorf("routetest: empty ride description")
+ }
+ for _, metric := range []struct {
+ name string
+ get func(section) *float64
+ }{
+ {"hr", func(s section) *float64 { return s.heartRate }},
+ {"cad", func(s section) *float64 { return s.cadence }},
+ {"w", func(s section) *float64 { return s.power }},
+ } {
+ present, complete := metricComplete(sections, metric.get)
+ if present && !complete {
+ return Built{}, fmt.Errorf("routetest: %s= must be set on every section or none", metric.name)
+ }
+ }
+ points := buildPoints(sections, o)
+ built := Built{
+ Distances: make([]float64, len(points)),
+ Elevations: make([]float64, len(points)),
+ Coords: make([]geodist.Coord, len(points)),
+ Timestamps: make([]time.Time, len(points)),
+ }
+ if sections[0].heartRate != nil {
+ built.HeartRates = make([]float64, len(points))
+ }
+ if sections[0].cadence != nil {
+ built.Cadences = make([]float64, len(points))
+ }
+ if sections[0].power != nil {
+ built.Powers = make([]float64, len(points))
+ }
+ for i, p := range points {
+ built.Distances[i] = p.distanceM
+ built.Elevations[i] = p.elevationM
+ built.Coords[i] = p.coord
+ built.Timestamps[i] = p.timestamp
+ if built.HeartRates != nil {
+ built.HeartRates[i] = *p.section.heartRate
+ }
+ if built.Cadences != nil {
+ built.Cadences[i] = *p.section.cadence
+ }
+ if built.Powers != nil {
+ built.Powers[i] = *p.section.power
+ }
+ }
+ return built, nil
+}
+
+// Route parses a description into a climb.Route containing only its geometry.
+func Route(dsl string, opts ...Option) (climb.Route, error) {
+ built, err := Build(dsl, opts...)
+ if err != nil {
+ return climb.Route{}, err
+ }
+ return climb.Route{
+ Distances: built.Distances,
+ Elevations: built.Elevations,
+ Coords: built.Coords,
+ }, nil
+}
+
+// MustRoute is like Route but panics on a malformed description; use it inside
+// tests where the description is a static program constant.
+func MustRoute(dsl string, opts ...Option) climb.Route {
+ route, err := Route(dsl, opts...)
+ if err != nil {
+ panic(err)
+ }
+ return route
+}
+
+func parseSections(dsl string) ([]section, error) {
+ rawSections := strings.Split(dsl, "|")
+ sections := make([]section, 0, len(rawSections))
+ for _, raw := range rawSections {
+ raw = strings.TrimSpace(raw)
+ if raw == "" {
+ return nil, fmt.Errorf("routetest: empty section in %q", dsl)
+ }
+ sec, err := parseSection(raw)
+ if err != nil {
+ return nil, err
+ }
+ sections = append(sections, sec)
+ }
+ return sections, nil
+}
+
+// parseSection parses a single section like "2km at 7% hr=150 cad=80 w=200".
+func parseSection(raw string) (section, error) {
+ fields := strings.Fields(raw)
+ if len(fields) < 3 {
+ return section{}, fmt.Errorf("routetest: section %q must look like \"2km at 7%%\"", raw)
+ }
+ if fields[1] != "at" {
+ return section{}, fmt.Errorf("routetest: section %q: expected \"at\" between distance and slope, got %q", raw, fields[1])
+ }
+ distanceKm, err := parseUnit(fields[0], "km")
+ if err != nil {
+ return section{}, fmt.Errorf("routetest: section %q: %w", raw, err)
+ }
+ slopePercent, err := parseUnit(fields[2], "%")
+ if err != nil {
+ return section{}, fmt.Errorf("routetest: section %q: %w", raw, err)
+ }
+ if distanceKm <= 0 {
+ return section{}, fmt.Errorf("routetest: section %q: distance must be positive", raw)
+ }
+ sec := section{
+ distanceM: distanceKm * 1000,
+ slope: slopePercent / 100,
+ }
+ for _, token := range fields[3:] {
+ var value *float64
+ switch {
+ case strings.HasPrefix(token, "hr="):
+ value, err = parseMetric(token, "hr")
+ sec.heartRate = value
+ case strings.HasPrefix(token, "cad="):
+ value, err = parseMetric(token, "cad")
+ sec.cadence = value
+ case strings.HasPrefix(token, "w="):
+ value, err = parseMetric(token, "w")
+ sec.power = value
+ default:
+ return section{}, fmt.Errorf("routetest: section %q: unknown metric %q (want hr=, cad= or w=)", raw, token)
+ }
+ if err != nil {
+ return section{}, fmt.Errorf("routetest: section %q: %w", raw, err)
+ }
+ }
+ return sec, nil
+}
+
+func parseUnit(token, unit string) (float64, error) {
+ if !strings.HasSuffix(token, unit) {
+ return 0, fmt.Errorf("expected a value in %s, got %q", unit, token)
+ }
+ return strconv.ParseFloat(strings.TrimSuffix(token, unit), 64)
+}
+
+func parseMetric(token, name string) (*float64, error) {
+ value, err := strconv.ParseFloat(strings.TrimPrefix(token, name+"="), 64)
+ if err != nil {
+ return nil, fmt.Errorf("invalid %s value %q", name, token)
+ }
+ return &value, nil
+}
+
+// metricComplete reports whether a metric column is present (any section sets
+// it) and complete (every section sets it).
+func metricComplete(sections []section, get func(section) *float64) (present, complete bool) {
+ set := 0
+ for _, sec := range sections {
+ if get(sec) != nil {
+ set++
+ }
+ }
+ return set > 0, set == len(sections)
+}
+
+func buildPoints(sections []section, o options) []point {
+ lat, lon := 0.0, 0.0
+ if o.startCoord != nil {
+ lat, lon = o.startCoord.Lat, o.startCoord.Lon
+ }
+ // Coordinates derive from the total distance so that exact multiples of
+ // the per-kilometer heading stay exact; a running accumulation drifts
+ // after a few points.
+ coord := func(distanceM float64) geodist.Coord {
+ return geodist.Coord{Lat: lat + o.headingLat*(distanceM/1000), Lon: lon + o.headingLon*(distanceM/1000)}
+ }
+ points := make([]point, 0, len(sections)+1)
+ elevation := o.startElevM
+ timestamp := o.startTime
+ points = append(points, point{
+ distanceM: 0,
+ elevationM: elevation,
+ coord: coord(0),
+ timestamp: timestamp,
+ section: sections[0],
+ })
+ distance := 0.0
+ for _, sec := range sections {
+ curSecDist := 0.0
+ for curSecDist < sec.distanceM {
+ curSecDist += o.samplingM
+ elevation += o.samplingM * sec.slope
+ timestamp = timestamp.Add(time.Second)
+ points = append(points, point{
+ distanceM: distance + curSecDist,
+ elevationM: elevation,
+ coord: coord(distance + curSecDist),
+ timestamp: timestamp,
+ section: sec,
+ })
+ }
+ distance += curSecDist
+ }
+ return points
+}
diff --git a/mountain_pass/detection.go b/mountain_pass/detection.go
index 40281f3..79e55e4 100644
--- a/mountain_pass/detection.go
+++ b/mountain_pass/detection.go
@@ -5,7 +5,8 @@ import (
"math"
"github.com/jftuga/geodist"
- "github.com/martinlehoux/biking_home/ride"
+ "github.com/martinlehoux/biking_home/climb"
+ "github.com/martinlehoux/biking_home/geo"
)
// MountainPass is a pass extracted from OpenStreetMap: a node tagged
@@ -112,9 +113,9 @@ func DetectCrossings(coords []geodist.Coord, distances, elevations []float64, pa
// MatchClimb returns the pass whose coordinates lie within radiusM of the
// climb's highest point and whose elevation is within elevationToleranceM of
// that point's elevation, nearest first. Found is false when no pass matches.
-func MatchClimb(climb ride.Climb, passes []MountainPass, radiusM, elevationToleranceM float64) (MountainPass, bool) {
- topCoord := climb.TopCoord()
- topElevation := climb.TopElevationM()
+func MatchClimb(segment climb.Climb, passes []MountainPass, radiusM, elevationToleranceM float64) (MountainPass, bool) {
+ topIndex, topElevation := segment.Top()
+ topCoord := segment.Coords[topIndex]
var best MountainPass
bestDistanceM := radiusM
found := false
@@ -139,21 +140,17 @@ func MatchClimb(climb ride.Climb, passes []MountainPass, radiusM, elevationToler
}
func nearestCrossing(coords []geodist.Coord, distances, elevations []float64, mountainPass MountainPass) (Crossing, bool) {
- best := Crossing{Pass: mountainPass, DistanceToM: 1e18}
- for i := range coords {
- distanceKm, _ := geodist.HaversineDistance(coords[i], *mountainPass.Coord)
- distanceM := distanceKm * 1000
- if distanceM < best.DistanceToM {
- best.DistanceToM = distanceM
- best.RideDistanceM = distances[i]
- best.RideElevation = elevations[i]
- best.ElevationDiff = absFloat64(elevations[i] - float64(mountainPass.Elevation))
- }
- }
- if best.DistanceToM > 1e17 {
- return best, false
+ index, distanceM := geo.Nearest(coords, *mountainPass.Coord)
+ if math.IsInf(distanceM, 1) {
+ return Crossing{Pass: mountainPass}, false
}
- return best, true
+ return Crossing{
+ Pass: mountainPass,
+ DistanceToM: distanceM,
+ RideDistanceM: distances[index],
+ RideElevation: elevations[index],
+ ElevationDiff: absFloat64(elevations[index] - float64(mountainPass.Elevation)),
+ }, true
}
func absFloat64(value float64) float64 {
diff --git a/mountain_pass/detection_test.go b/mountain_pass/detection_test.go
index 6318ca3..6b297b5 100644
--- a/mountain_pass/detection_test.go
+++ b/mountain_pass/detection_test.go
@@ -5,8 +5,10 @@ import (
"testing"
"github.com/jftuga/geodist"
+ "github.com/martinlehoux/biking_home/climb"
"github.com/martinlehoux/biking_home/internal/dbtest"
"github.com/martinlehoux/biking_home/internal/ridetest"
+ "github.com/martinlehoux/biking_home/internal/routetest"
"github.com/martinlehoux/biking_home/mountain_pass"
"github.com/martinlehoux/biking_home/ride"
"github.com/stretchr/testify/assert"
@@ -19,7 +21,7 @@ func TestDetectCrossingsFindsPass(t *testing.T) {
Name: "Pas de Magnan", Elevation: 460,
Coord: &geodist.Coord{Lat: 43.62, Lon: 5.43},
}
- crossings := mountain_pass.DetectCrossings(r.Coords(), r.Distances(), r.Elevations(), []mountain_pass.MountainPass{pass}, 100, 25)
+ crossings := mountain_pass.DetectCrossings(r.Coords, r.Distances, r.Elevations, []mountain_pass.MountainPass{pass}, 100, 25)
assert.Len(t, crossings, 1)
assert.Equal(t, "Pas de Magnan", crossings[0].Pass.Name)
assert.Less(t, crossings[0].DistanceToM, 10.0)
@@ -42,7 +44,7 @@ func TestLoadMountainPassesAroundRideUsesExpandedBounds(t *testing.T) {
require.NoError(t, err)
route := ridetest.MustRide("1km at 10%", ridetest.StartCoord(43.0, 5.0), ridetest.Heading(0.1, 0.1), ridetest.StartElevation(100), ridetest.Sampling(1000))
- passes, err := mountain_pass.LoadMountainPassesAroundRide(db, route.Coords(), 10_000)
+ passes, err := mountain_pass.LoadMountainPassesAroundRide(db, route.Coords, 10_000)
require.NoError(t, err)
require.Len(t, passes, 3)
assert.Equal(t, "On route edge", passes[0].Name)
@@ -77,13 +79,13 @@ func TestDetectCrossingsIgnoresDistantPass(t *testing.T) {
Name: "Col de la Faucille", Elevation: 1320,
Coord: &geodist.Coord{Lat: 46.37, Lon: 6.02},
}
- assert.Empty(t, mountain_pass.DetectCrossings(r.Coords(), r.Distances(), r.Elevations(), []mountain_pass.MountainPass{far}, 100, 25))
+ assert.Empty(t, mountain_pass.DetectCrossings(r.Coords, r.Distances, r.Elevations, []mountain_pass.MountainPass{far}, 100, 25))
}
func TestDetectCrossingsIgnoresPassWithoutCoordinates(t *testing.T) {
r := rideFromPoint(t, 43.62, 5.43, 447)
noCoord := mountain_pass.MountainPass{Name: "Col de la Gatasse", Elevation: 120}
- assert.Empty(t, mountain_pass.DetectCrossings(r.Coords(), r.Distances(), r.Elevations(), []mountain_pass.MountainPass{noCoord}, 100, 25))
+ assert.Empty(t, mountain_pass.DetectCrossings(r.Coords, r.Distances, r.Elevations, []mountain_pass.MountainPass{noCoord}, 100, 25))
}
func TestDetectCrossingsRequiresElevationAgreement(t *testing.T) {
@@ -92,7 +94,7 @@ func TestDetectCrossingsRequiresElevationAgreement(t *testing.T) {
Name: "Pas de Magnan", Elevation: 900,
Coord: &geodist.Coord{Lat: 43.62, Lon: 5.43},
}
- assert.Empty(t, mountain_pass.DetectCrossings(r.Coords(), r.Distances(), r.Elevations(), []mountain_pass.MountainPass{pass}, 100, 25))
+ assert.Empty(t, mountain_pass.DetectCrossings(r.Coords, r.Distances, r.Elevations, []mountain_pass.MountainPass{pass}, 100, 25))
}
// rideFromPoint builds a 1 km ride ending at (latitude, longitude) at the
@@ -144,10 +146,10 @@ func TestMatchClimbNearestOfTwo(t *testing.T) {
// climbFixture builds a 2 km climb from 300 m up to 447 m then back down to
// 380 m, heading northeast from (43.61, 5.42).
-func climbFixture() ride.Climb {
- r := ridetest.MustRide("1km at 14.7% | 1km at -6.7%",
- ridetest.StartCoord(43.61, 5.42),
- ridetest.StartElevation(300),
- ridetest.Sampling(1000))
- return r.ClimbFromDist(0, 2000)
+func climbFixture() climb.Climb {
+ route := routetest.MustRoute("1km at 14.7% | 1km at -6.7%",
+ routetest.StartCoord(43.61, 5.42),
+ routetest.StartElevation(300),
+ routetest.Sampling(1000))
+ return climb.Climb{Route: route}
}
diff --git a/official_climb/official_climb_test.go b/official_climb/official_climb_test.go
index ed121e5..6ddce5a 100644
--- a/official_climb/official_climb_test.go
+++ b/official_climb/official_climb_test.go
@@ -56,10 +56,10 @@ func TestMatchClimbMatchesWithoutDetectedClimb(t *testing.T) {
parsed := ridetest.MustRide("2km at 5% | 1km at -10%", ridetest.StartCoord(43.10, 5.10), ridetest.StartElevation(100), ridetest.Sampling(1000))
official := official_climb.OfficialClimb{
Name: "On route",
- StartCoord: parsed.Coords()[1],
- EndCoord: parsed.Coords()[2],
+ StartCoord: parsed.Coords[1],
+ EndCoord: parsed.Coords[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)
@@ -72,7 +72,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)
@@ -82,10 +82,10 @@ func TestMatchClimbRejectsReversedEndpoints(t *testing.T) {
parsed := ridetest.MustRide("2km at 5% | 1km at -10%", ridetest.StartCoord(43.10, 5.10), ridetest.StartElevation(100), ridetest.Sampling(1000))
official := official_climb.OfficialClimb{
Name: "Reversed",
- StartCoord: parsed.Coords()[2],
- EndCoord: parsed.Coords()[1],
+ StartCoord: parsed.Coords[2],
+ EndCoord: parsed.Coords[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)
}
@@ -94,9 +94,9 @@ func TestMatchClimbRejectsEndpointOutsideRadius(t *testing.T) {
official := official_climb.OfficialClimb{
Name: "Too far",
StartCoord: geodist.Coord{Lat: 43.09, Lon: 5.09},
- EndCoord: parsed.Coords()[2],
+ EndCoord: parsed.Coords[2],
}
- _, found := official_climb.MatchClimb(parsed.Coords(), official, 10)
+ _, found := official_climb.MatchClimb(parsed.Coords, official, 10)
assert.False(t, found)
}
@@ -122,14 +122,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)
}
@@ -154,7 +154,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)
@@ -177,7 +177,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)
}
@@ -203,7 +203,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
deleted file mode 100644
index 013104e..0000000
--- a/ride/climb.go
+++ /dev/null
@@ -1,329 +0,0 @@
-package ride
-
-import (
- "fmt"
- "log/slog"
- "math"
- "slices"
- "time"
-
- "github.com/jftuga/geodist"
- "github.com/martinlehoux/kagamigo/kcore"
-)
-
-const ClimbDistanceMinimum = 500
-
-// ClimbSlopeMinimum is the minimum average slope (ratio) of a detected climb;
-// gentler regions are re-segmented into their qualifying steeper parts.
-const ClimbSlopeMinimum = 0.035
-
-// Category is the difficulty class of a climb, ordered from easiest to
-// hardest.
-type Category int
-
-const (
- CategoryNo Category = iota
- CategoryFour
- CategoryThree
- CategoryTwo
- CategoryOne
- CategoryHC
-)
-
-type categoryInfo struct {
- label string
- minScore float64
-}
-
-// categories describes each Category: its display label and the inclusive
-// Cotacol score floor at which it starts applying. A score at or above
-// CategoryOne's floor is Hors Catégorie; CategoryNo has no floor.
-var categories = map[Category]categoryInfo{
- CategoryNo: {label: "NO", minScore: math.Inf(-1)},
- CategoryFour: {label: "Cat 4", minScore: 35},
- CategoryThree: {label: "Cat 3", minScore: 80},
- CategoryTwo: {label: "Cat 2", minScore: 180},
- CategoryOne: {label: "Cat 1", minScore: 250},
- CategoryHC: {label: "HC", minScore: 600},
-}
-
-// climbDetectionMinimumScore is the climb-detection score below which a
-// candidate segment is not reported as a climb.
-const climbDetectionMinimumScore = 35
-
-type Climb struct {
- distances []float64
- elevations []float64
- coords []geodist.Coord
- timestamps []time.Time
- rideStart int
- rideEnd int
- Name string
-}
-
-func (climb Climb) Duration() time.Duration {
- return climb.timestamps[climb.rideEnd].Sub(climb.timestamps[climb.rideStart])
-}
-
-func (climb Climb) Speed() float64 {
- return (climb.EndDistanceM() - climb.StartDistanceM()) / climb.Duration().Seconds()
-}
-
-func (climb Climb) StartIndex() int { return climb.rideStart }
-
-func (climb Climb) EndIndex() int { return climb.rideEnd }
-
-func (climb Climb) StartDistanceM() float64 {
- return climb.distances[climb.rideStart]
-}
-
-func (climb Climb) EndDistanceM() float64 {
- return climb.distances[climb.rideEnd]
-}
-
-func (climb Climb) StartCoord() geodist.Coord {
- return climb.coords[climb.rideStart]
-}
-
-func (climb Climb) EndCoord() geodist.Coord {
- 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.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.elevations[i] > climb.elevations[top] {
- top = i
- }
- }
- return top
-}
-
-func (climb Climb) TopDistanceM() float64 {
- return climb.distances[climb.TopIndex()]
-}
-
-func (climb Climb) TopElevationM() float64 {
- return climb.elevations[climb.TopIndex()]
-}
-
-func (climb Climb) TopCoord() geodist.Coord {
- 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.distances, climb.elevations, climb.rideStart, climb.rideEnd)*100, int(cotacol), CategoryOf(cotacol))
- if climb.Name == "" {
- return body
- }
- return climb.Name + ": " + body
-}
-
-func (climb Climb) DifficultyScore() float64 {
- return difficultyScore(climb.distances, climb.elevations, climb.rideStart, climb.rideEnd)
-}
-
-func Slope(distances, elevations []float64, start, end int) float64 {
- return (elevations[end] - elevations[start]) / (distances[end] - distances[start])
-}
-
-func climbDetectionScore(distances, elevations []float64, start, end int) float64 {
- kcore.Assert(end > start, "no points for climb detection")
- distance := distances[end] - distances[start]
- if distance == 0 {
- return 0
- }
- dElevation := elevations[end] - elevations[start]
- return math.Abs(dElevation) * dElevation / distance * 100.0 * 100.0 / 1000.0
-}
-
-func (c Category) String() string {
- return categories[c].label
-}
-
-// CategoryOf returns the difficulty class of a Cotacol score: the category
-// with the highest floor the score reaches.
-func CategoryOf(cotacol float64) Category {
- best := CategoryNo
- bestMin := math.Inf(-1)
- found := false
- for category, info := range categories {
- if cotacol >= info.minScore && (!found || info.minScore > bestMin) {
- best = category
- bestMin = info.minScore
- found = true
- }
- }
- if !found {
- // NaN reaches no floor; preserve the original switch default (HC).
- return CategoryHC
- }
- 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(distances, elevations []float64, start, end int, minSlope float64) (interval, bool) {
- kcore.Assert(end > start, "empty points")
-
- bestDetectionScore := climbDetectionScore(distances, elevations, start, end)
- bestStart := start
- for i := start; i < end; i++ {
- detectionScore := climbDetectionScore(distances, elevations, i, end)
- if detectionScore > bestDetectionScore {
- bestStart = i
- bestDetectionScore = detectionScore
- }
- }
- bestEnd := end
- for i := end; i > bestStart; i-- {
- detectionScore := climbDetectionScore(distances, elevations, bestStart, i)
- if detectionScore > bestDetectionScore {
- bestEnd = i
- bestDetectionScore = detectionScore
- }
- }
- for i := bestStart; i < bestEnd; i++ {
- detectionScore := climbDetectionScore(distances, elevations, i, bestEnd)
- if detectionScore > bestDetectionScore {
- bestStart = i
- bestDetectionScore = detectionScore
- }
- }
- kcore.Assert(bestStart < bestEnd, "empty climb")
- 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 Slope(distances, elevations, i, j) < minSlope {
- continue
- }
- detectionScore := climbDetectionScore(distances, elevations, i, j)
- if detectionScore > bestDetectionScore {
- bestDetectionScore = detectionScore
- bestStart, bestEnd = i, j
- }
- }
- }
- if bestStart < 0 {
- return interval{}, false
- }
- return interval{bestStart, bestEnd}, true
-}
-
-// 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(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, start+1, end, 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(distances, elevations, highest, end, minSlope)
- }
- 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(distances, elevations, start, climb.start, minSlope)...)
- climbs = append(climbs, climbsBetween(distances, elevations, climb.end, end, minSlope)...)
-
- return climbs
-}
-
-type Range struct {
- Start int
- End int
-}
-
-func isValidClaim(claim Range, routeLen int) bool {
- return claim.Start >= 0 && claim.End < routeLen && claim.Start < claim.End
-}
-
-// 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 []Range) []Range {
- valid := make([]Range, 0, len(claimed))
- for _, claim := range claimed {
- if !isValidClaim(claim, rEnd+1) {
- continue
- }
- valid = append(valid, claim)
- }
- slices.SortFunc(valid, func(a, b Range) int { return a.Start - b.Start })
- regions := make([]Range, 0, len(valid)+1)
- current := rStart
- for _, claim := range valid {
- if claim.End < current {
- continue
- }
- if claim.Start > current {
- regions = append(regions, Range{Start: current, End: claim.Start - 1})
- }
- if claim.End+1 > current {
- current = claim.End + 1
- }
- }
- if current <= rEnd {
- regions = append(regions, Range{Start: current, End: rEnd})
- }
- return regions
-}
-
-// 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 {
- bounds := make([]interval, 0)
- for _, region := range freeRegions(0, ride.Len()-1, claimed) {
- 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))
- }
- return climbs
-}
-
-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/climb_test.go b/ride/climb_test.go
deleted file mode 100644
index 3632da7..0000000
--- a/ride/climb_test.go
+++ /dev/null
@@ -1,135 +0,0 @@
-package ride_test
-
-import (
- "testing"
-
- "github.com/martinlehoux/biking_home/internal/ridetest"
- "github.com/martinlehoux/biking_home/ride"
- "github.com/stretchr/testify/assert"
- "github.com/stretchr/testify/require"
-)
-
-var parser = ride.GPXRideParser{}
-
-func TestClimbThenFalseFlat(t *testing.T) {
- r := ridetest.MustRide("2km at 7% | 10km at 1%")
- climbs := r.DetectClimbs(nil, ride.ClimbSlopeMinimum)
-
- assert.Len(t, climbs, 1)
- assert.Equal(t, "0.0km-2.0km: 2.0km at 7.0% (98 pts - Cat 3)", climbs[0].String())
-}
-
-func TestSmallClimbWithDescentInsideFalseFlat(t *testing.T) {
- r := ridetest.MustRide("10km at 1% | 2km at 7% | 1km at -7% | 10km at 1%")
- climbs := r.DetectClimbs(nil, ride.ClimbSlopeMinimum)
-
- assert.Len(t, climbs, 1)
- assert.Equal(t, "10.0km-12.0km: 2.0km at 7.0% (98 pts - Cat 3)", climbs[0].String())
-}
-
-func TestPogacar20220721(t *testing.T) {
- r, err := ride.ParseFile(parser, "../examples/2022-07-21.Pogacar.gpx")
- assert.NoError(t, err)
- climbs := r.DetectClimbs(nil, ride.ClimbSlopeMinimum)
-
- assert.Len(t, climbs, 6)
- assert.Equal(t, "0.6km-5.6km: 5.0km at 3.5% (92 pts - Cat 3)", climbs[0].String())
- assert.Equal(t, "42.9km-45.2km: 2.3km at 5.0% (74 pts - Cat 4)", climbs[1].String())
- assert.Equal(t, "60.0km-76.4km: 16.4km at 7.2% (977 pts - HC)", climbs[2].String())
- assert.Equal(t, "83.8km-85.9km: 2.0km at 5.4% (79 pts - Cat 4)", climbs[3].String())
- assert.Equal(t, "99.2km-109.3km: 10.2km at 8.4% (751 pts - HC)", climbs[4].String())
- assert.Equal(t, "128.6km-142.2km: 13.6km at 7.8% (930 pts - HC)", climbs[5].String())
-}
-
-func TestBouclesVerdon2024(t *testing.T) {
- r, err := ride.ParseFile(parser, "../examples/2024-06-29.BouclesVerdon.gpx")
- assert.NoError(t, err)
- climbs := r.DetectClimbs(nil, ride.ClimbSlopeMinimum)
-
- assert.Len(t, climbs, 6)
- assert.Equal(t, "6.8km-9.1km: 2.3km at 4.0% (41 pts - Cat 4)", climbs[0].String())
- assert.Equal(t, "34.7km-37.3km: 2.5km at 5.0% (67 pts - Cat 4)", climbs[1].String())
- assert.Equal(t, "41.9km-43.8km: 1.8km at 4.6% (39 pts - Cat 4)", climbs[2].String())
- assert.Equal(t, "57.6km-60.8km: 3.1km at 6.3% (139 pts - Cat 3)", climbs[3].String())
- assert.Equal(t, "71.7km-74.7km: 3.0km at 5.3% (89 pts - Cat 3)", climbs[4].String())
- assert.Equal(t, "77.4km-78.7km: 1.3km at 5.3% (39 pts - Cat 4)", climbs[5].String())
-}
-
-func TestMimetArbois20241229(t *testing.T) {
- r, err := ride.ParseFile(parser, "../examples/2024-12-29.MimetArbois.gpx")
- assert.NoError(t, err)
- climbs := r.DetectClimbs(nil, ride.ClimbSlopeMinimum)
-
- assert.Len(t, climbs, 2)
- assert.Equal(t, "3.9km-5.3km: 1.4km at 10.3% (165 pts - Cat 3)", climbs[0].String())
- assert.Equal(t, "14.4km-19.8km: 5.4km at 4.3% (113 pts - Cat 3)", climbs[1].String())
-}
-
-func TestMimetArbois20250104(t *testing.T) {
- r, err := ride.ParseFile(parser, "../examples/2025-01-04.MimetArbois.gpx")
- assert.NoError(t, err)
- climbs := r.DetectClimbs(nil, ride.ClimbSlopeMinimum)
-
- assert.Len(t, climbs, 2)
- assert.Equal(t, "4.5km-5.9km: 1.4km at 10.1% (159 pts - Cat 3)", climbs[0].String())
- assert.Equal(t, "12.0km-15.1km: 3.2km at 4.5% (99 pts - Cat 3)", climbs[1].String())
-}
-
-// https://www.strava.com/activities/9282265844
-func TestAlpesVerdonTour20230617(t *testing.T) {
- r, err := ride.ParseFile(parser, "../examples/2023-06-17.AlpesVerdonTour.gpx")
- assert.NoError(t, err)
- climbs := r.DetectClimbs(nil, ride.ClimbSlopeMinimum)
-
- assert.Len(t, climbs, 6)
- assert.Equal(t, "0.7km-4.6km: 3.9km at 4.2% (72 pts - Cat 4)", climbs[0].String())
- assert.Equal(t, "8.2km-18.6km: 10.3km at 4.4% (292 pts - Cat 1)", climbs[1].String())
- assert.Equal(t, "36.1km-41.6km: 5.4km at 5.8% (193 pts - Cat 2)", climbs[2].String())
- assert.Equal(t, "47.0km-48.2km: 1.2km at 6.1% (45 pts - Cat 4)", climbs[3].String())
- assert.Equal(t, "51.9km-54.7km: 2.8km at 4.0% (48 pts - Cat 4)", climbs[4].String())
- assert.Equal(t, "114.1km-114.7km: 0.6km at 14.3% (119 pts - Cat 3)", climbs[5].String())
-}
-
-func TestDetectClimbsSkipsMiddleClaim(t *testing.T) {
- // A climb, a false flat giving a saddle, then a climb again. Claiming the
- // saddle's descent base should leave both climbs detected separately.
- r := ridetest.MustRide("2km at 7% | 1km at -7% | 2km at 7%")
- climbs := r.DetectClimbs([]ride.Range{{Start: 20, End: 20}}, ride.ClimbSlopeMinimum)
-
- // No free region falls under the min distance except the two climbs.
- assert.Len(t, climbs, 2)
- assert.Equal(t, "0.0km-2.0km: 2.0km at 7.0% (98 pts - Cat 3)", climbs[0].String())
- assert.Equal(t, "3.0km-5.0km: 2.0km at 7.0% (98 pts - Cat 3)", climbs[1].String())
-}
-
-func TestDetectClimbsOverlappingClaimDropsClimb(t *testing.T) {
- // Claiming the ascent region entirely swallows the climb.
- r := ridetest.MustRide("2km at 7% | 20km at 0%")
- climbs := r.DetectClimbs([]ride.Range{{Start: 0, End: 40}}, ride.ClimbSlopeMinimum)
- assert.Empty(t, climbs)
-}
-
-func TestDetectClimbsIgnoresOutOfRangeClaim(t *testing.T) {
- r := ridetest.MustRide("2km at 7% | 20km at 0%")
- climbs := r.DetectClimbs([]ride.Range{{Start: -5, End: -1}}, ride.ClimbSlopeMinimum)
- require.Len(t, climbs, 1)
- assert.Equal(t, "0.0km-2.0km: 2.0km at 7.0% (98 pts - Cat 3)", climbs[0].String())
-}
-
-func TestDetectClimbsWithUnsortedClaimsDropsOverlappingClimb(t *testing.T) {
- // Reproduces ride 6 Chemin des Lauriers bug: DetectClimbs must not depend on
- // caller-provided order. Two official claims out of ID order (Bastides
- // 2140-2627 before Lauriers 1215-1588) caused freeRegions to skip the
- // earlier claim and leave the overlapping Cat 4 1352-1601 visible.
- // Minimal reproduction: two climbs 0-2km and 3-5km separated by a descent.
- r := ridetest.MustRide("2km at 7% | 1km at -7% | 2km at 7%")
- // Both climbs claimed, but in reverse order (mimics ORDER BY id returning 30-50 before 0-20).
- // Sorted input correctly yields 0 detected climbs.
- sorted := []ride.Range{{Start: 0, End: 20}, {Start: 30, End: 50}}
- assert.Empty(t, r.DetectClimbs(sorted, ride.ClimbSlopeMinimum))
-
- unsorted := []ride.Range{{Start: 30, End: 50}, {Start: 0, End: 20}}
- climbs := r.DetectClimbs(unsorted, ride.ClimbSlopeMinimum)
- // Before fix this fails: freeRegions skips {0,20} because 20 < 30 and returns 1 climb.
- assert.Empty(t, climbs, "unsorted claimed ranges must still suppress overlapping climbs — expected 0, got %v", climbs)
-}
diff --git a/ride/detection_example_test.go b/ride/detection_example_test.go
new file mode 100644
index 0000000..053abbb
--- /dev/null
+++ b/ride/detection_example_test.go
@@ -0,0 +1,87 @@
+package ride_test
+
+import (
+ "fmt"
+ "testing"
+
+ "github.com/martinlehoux/biking_home/climb"
+ "github.com/martinlehoux/biking_home/ride"
+ "github.com/stretchr/testify/assert"
+)
+
+var parser = ride.GPXRideParser{}
+
+// describeClimb renders a detected climb as the ride-style
+// "start-end: length at slope (pts - cat)" string, so positions are asserted.
+func describeClimb(route climb.Route, detected climb.DetectedClimb) string {
+ seg := detected.Climb
+ startKm := route.Distances[detected.Start] / 1000
+ endKm := route.Distances[detected.Start+len(seg.Distances)-1] / 1000
+ lengthKm := (seg.Distances[len(seg.Distances)-1] - seg.Distances[0]) / 1000
+ slopePercent := climb.Slope(seg.Distances, seg.Elevations) * 100
+ pts := int(seg.DifficultyScore())
+ return fmt.Sprintf("%.1fkm-%.1fkm: %.1fkm at %.1f%% (%d pts - %s)", startKm, endKm, lengthKm, slopePercent, pts, climb.CategoryOf(seg.DifficultyScore()))
+}
+
+func TestPogacar20220721(t *testing.T) {
+ r, err := ride.ParseFile(parser, "../examples/2022-07-21.Pogacar.gpx")
+ assert.NoError(t, err)
+ detected := climb.Detect(r.Route, nil, climb.ClimbSlopeMinimum)
+
+ assert.Len(t, detected, 6)
+ assert.Equal(t, "0.6km-5.6km: 5.0km at 3.5% (92 pts - Cat 3)", describeClimb(r.Route, detected[0]))
+ assert.Equal(t, "42.9km-45.2km: 2.3km at 5.0% (74 pts - Cat 4)", describeClimb(r.Route, detected[1]))
+ assert.Equal(t, "60.0km-76.4km: 16.4km at 7.2% (977 pts - HC)", describeClimb(r.Route, detected[2]))
+ assert.Equal(t, "83.8km-85.9km: 2.1km at 5.4% (79 pts - Cat 4)", describeClimb(r.Route, detected[3]))
+ assert.Equal(t, "99.2km-109.3km: 10.2km at 8.4% (751 pts - HC)", describeClimb(r.Route, detected[4]))
+ assert.Equal(t, "128.6km-142.2km: 13.6km at 7.8% (930 pts - HC)", describeClimb(r.Route, detected[5]))
+}
+
+func TestBouclesVerdon2024(t *testing.T) {
+ r, err := ride.ParseFile(parser, "../examples/2024-06-29.BouclesVerdon.gpx")
+ assert.NoError(t, err)
+ detected := climb.Detect(r.Route, nil, climb.ClimbSlopeMinimum)
+
+ assert.Len(t, detected, 6)
+ assert.Equal(t, "6.8km-9.1km: 2.3km at 4.0% (41 pts - Cat 4)", describeClimb(r.Route, detected[0]))
+ assert.Equal(t, "34.7km-37.3km: 2.5km at 5.0% (67 pts - Cat 4)", describeClimb(r.Route, detected[1]))
+ assert.Equal(t, "41.9km-43.8km: 1.8km at 4.6% (39 pts - Cat 4)", describeClimb(r.Route, detected[2]))
+ assert.Equal(t, "57.6km-60.8km: 3.1km at 6.3% (139 pts - Cat 3)", describeClimb(r.Route, detected[3]))
+ assert.Equal(t, "71.7km-74.7km: 3.0km at 5.3% (90 pts - Cat 3)", describeClimb(r.Route, detected[4]))
+ assert.Equal(t, "77.4km-78.7km: 1.3km at 5.3% (39 pts - Cat 4)", describeClimb(r.Route, detected[5]))
+}
+
+func TestMimetArbois20241229(t *testing.T) {
+ r, err := ride.ParseFile(parser, "../examples/2024-12-29.MimetArbois.gpx")
+ assert.NoError(t, err)
+ detected := climb.Detect(r.Route, nil, climb.ClimbSlopeMinimum)
+
+ assert.Len(t, detected, 2)
+ assert.Equal(t, "3.9km-5.3km: 1.4km at 10.3% (165 pts - Cat 3)", describeClimb(r.Route, detected[0]))
+ assert.Equal(t, "14.4km-19.8km: 5.4km at 4.3% (113 pts - Cat 3)", describeClimb(r.Route, detected[1]))
+}
+
+func TestMimetArbois20250104(t *testing.T) {
+ r, err := ride.ParseFile(parser, "../examples/2025-01-04.MimetArbois.gpx")
+ assert.NoError(t, err)
+ detected := climb.Detect(r.Route, nil, climb.ClimbSlopeMinimum)
+
+ assert.Len(t, detected, 2)
+ assert.Equal(t, "4.5km-5.9km: 1.4km at 10.1% (159 pts - Cat 3)", describeClimb(r.Route, detected[0]))
+ assert.Equal(t, "12.0km-15.1km: 3.2km at 4.5% (99 pts - Cat 3)", describeClimb(r.Route, detected[1]))
+}
+
+// https://www.strava.com/activities/9282265844
+func TestAlpesVerdonTour20230617(t *testing.T) {
+ r, err := ride.ParseFile(parser, "../examples/2023-06-17.AlpesVerdonTour.gpx")
+ assert.NoError(t, err)
+ detected := climb.Detect(r.Route, nil, climb.ClimbSlopeMinimum)
+
+ assert.Len(t, detected, 6)
+ assert.Equal(t, "0.7km-4.6km: 3.9km at 4.2% (72 pts - Cat 4)", describeClimb(r.Route, detected[0]))
+ assert.Equal(t, "8.2km-18.6km: 10.3km at 4.4% (292 pts - Cat 1)", describeClimb(r.Route, detected[1]))
+ assert.Equal(t, "36.1km-41.6km: 5.4km at 5.8% (193 pts - Cat 2)", describeClimb(r.Route, detected[2]))
+ assert.Equal(t, "47.0km-48.2km: 1.2km at 6.1% (45 pts - Cat 4)", describeClimb(r.Route, detected[3]))
+ assert.Equal(t, "51.9km-54.7km: 2.8km at 4.0% (48 pts - Cat 4)", describeClimb(r.Route, detected[4]))
+ assert.Equal(t, "114.1km-114.7km: 0.6km at 14.3% (119 pts - Cat 3)", describeClimb(r.Route, detected[5]))
+}
diff --git a/ride/difficulty_example_test.go b/ride/difficulty_example_test.go
new file mode 100644
index 0000000..bd87f58
--- /dev/null
+++ b/ride/difficulty_example_test.go
@@ -0,0 +1,25 @@
+package ride_test
+
+import (
+ "testing"
+
+ "github.com/martinlehoux/biking_home/climb"
+ "github.com/martinlehoux/biking_home/ride"
+ "github.com/stretchr/testify/assert"
+)
+
+func TestCotacolExampleRide(t *testing.T) {
+ r, err := ride.ParseFile(parser, "../examples/2022-07-21.Pogacar.gpx")
+ assert.NoError(t, err)
+ assert.InDelta(t, 3049.0, climb.Cotacol(r.Distances, r.Elevations), 1)
+}
+
+func TestCotacolHautacamClimbfinderReference(t *testing.T) {
+ r, err := ride.ParseFile(parser, "../examples/2022-07-21.Pogacar.gpx")
+ assert.NoError(t, err)
+ detected := climb.Detect(r.Route, nil, climb.ClimbSlopeMinimum)
+ assert.Len(t, detected, 6)
+ hautacam := detected[5].Climb
+ assert.InDelta(t, 128.6, hautacam.Distances[0]/1000, 0.1)
+ assert.InDelta(t, 930.0, hautacam.DifficultyScore(), 10)
+}
diff --git a/ride/difficulty_test.go b/ride/difficulty_test.go
deleted file mode 100644
index bd316b4..0000000
--- a/ride/difficulty_test.go
+++ /dev/null
@@ -1,87 +0,0 @@
-package ride_test
-
-import (
- "math"
- "testing"
-
- "github.com/martinlehoux/biking_home/internal/ridetest"
- "github.com/martinlehoux/biking_home/ride"
- "github.com/stretchr/testify/assert"
-)
-
-func TestCotacolConstantClimb(t *testing.T) {
- r := ridetest.MustRide("2km at 7%")
- assert.InDelta(t, 98.0, ride.Cotacol(r), 1e-9)
-}
-
-func TestCotacolIgnoresDescent(t *testing.T) {
- r := ridetest.MustRide("2km at 7% | 2km at -7%")
- assert.InDelta(t, 98.0, ride.Cotacol(r), 1e-9)
-}
-
-func TestCotacolFlatIsZero(t *testing.T) {
- r := ridetest.MustRide("5km at 0%")
- assert.Zero(t, ride.Cotacol(r))
-}
-
-func TestCotacolSteepShortCountsMore(t *testing.T) {
- steep := ridetest.MustRide("0.5km at 14%")
- long := ridetest.MustRide("1km at 7%")
- assert.InDelta(t, 98.0, ride.Cotacol(steep), 1e-9)
- assert.InDelta(t, 49.0, ride.Cotacol(long), 1e-9)
-}
-
-func TestCotacolPrecisionInvariant(t *testing.T) {
- coarse := ridetest.MustRide("3km at 5%")
- fine := ridetest.MustRide("3km at 5%", ridetest.Sampling(50))
- assert.InDelta(t, ride.Cotacol(coarse), ride.Cotacol(fine), 1e-9)
-}
-
-func TestCotacolExampleRide(t *testing.T) {
- r, err := ride.ParseFile(parser, "../examples/2022-07-21.Pogacar.gpx")
- assert.NoError(t, err)
- assert.InDelta(t, 3049.0, ride.Cotacol(r), 1)
-}
-
-func TestCotacolClimbNotAtStart(t *testing.T) {
- r := ridetest.MustRide("1km at 0% | 2km at 7%")
- climbs := r.DetectClimbs(nil, ride.ClimbSlopeMinimum)
- assert.Len(t, climbs, 1)
- assert.InDelta(t, 98.0, climbs[0].DifficultyScore(), 1e-9)
-}
-
-func TestCategoryOfBoundaries(t *testing.T) {
- cases := []struct {
- score float64
- want ride.Category
- }{
- {-5, ride.CategoryNo},
- {30, ride.CategoryNo},
- {35, ride.CategoryFour}, // 35 is the first floor, not below it
- {79, ride.CategoryFour},
- {80, ride.CategoryThree},
- {179, ride.CategoryThree},
- {180, ride.CategoryTwo},
- {249, ride.CategoryTwo},
- {250, ride.CategoryOne},
- {599, ride.CategoryOne},
- {600, ride.CategoryHC},
- {700, ride.CategoryHC},
- {math.NaN(), ride.CategoryHC}, // reaches no floor, like the original switch default
- }
- for _, c := range cases {
- assert.Equal(t, c.want, ride.CategoryOf(c.score), "score %v", c.score)
- }
- assert.Equal(t, "Cat 1", ride.CategoryOne.String())
- assert.Equal(t, "NO", ride.CategoryNo.String())
-}
-
-func TestCotacolHautacamClimbfinderReference(t *testing.T) {
- r, err := ride.ParseFile(parser, "../examples/2022-07-21.Pogacar.gpx")
- assert.NoError(t, err)
- climbs := r.DetectClimbs(nil, ride.ClimbSlopeMinimum)
- assert.Len(t, climbs, 6)
- hautacam := climbs[5]
- assert.InDelta(t, 128.6, hautacam.StartDistanceM()/1000, 0.1)
- assert.InDelta(t, 930.0, hautacam.DifficultyScore(), 10)
-}
diff --git a/ride/ecs_test.go b/ride/ecs_test.go
deleted file mode 100644
index 01c0628..0000000
--- a/ride/ecs_test.go
+++ /dev/null
@@ -1,43 +0,0 @@
-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 2ab07bf..62deb24 100644
--- a/ride/moving_time.go
+++ b/ride/moving_time.go
@@ -19,7 +19,7 @@ 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.timestamps, r.distances, r.powers, r.cadences, movingTimeMinSpeedMps, movingTimeMaxGap)
+ return movingTime(r.timestamps, r.Distances, r.powers, r.cadences, movingTimeMinSpeedMps, movingTimeMaxGap)
}
func movingTime(timestamps []time.Time, distances, powers, cadences []float64, minSpeedMps float64, maxGap time.Duration) time.Duration {
diff --git a/ride/parser_test.go b/ride/parser_test.go
index d8ac4d6..b9f0fb1 100644
--- a/ride/parser_test.go
+++ b/ride/parser_test.go
@@ -24,7 +24,7 @@ func TestGPXRideParserSkipsStationaryPoints(t *testing.T) {
parsed, err := (ride.GPXRideParser{}).Parse(strings.NewReader(data))
require.NoError(t, err)
assert.Equal(t, 2, parsed.Len())
- assert.Greater(t, parsed.Distances()[1], 0.0)
+ assert.Greater(t, parsed.Distances[1], 0.0)
}
func TestGPXRideParserRejectsStationaryRide(t *testing.T) {
diff --git a/ride/ride.go b/ride/ride.go
index 7863878..8e8bf95 100644
--- a/ride/ride.go
+++ b/ride/ride.go
@@ -4,13 +4,13 @@ import (
"errors"
"fmt"
"io"
- "math"
"os"
"strconv"
"strings"
"time"
"github.com/jftuga/geodist"
+ "github.com/martinlehoux/biking_home/climb"
"github.com/martinlehoux/kagamigo/kcore"
"github.com/tkrajina/gpxgo/gpx"
)
@@ -20,9 +20,7 @@ const garminTrackPointExtensionNamespace = "http://www.garmin.com/xmlschemas/Tra
var ErrNoTrackPoints = errors.New("ride has no track points")
type Ride struct {
- distances []float64
- elevations []float64
- coords []geodist.Coord
+ climb.Route
timestamps []time.Time
heartRates []float64
cadences []float64
@@ -31,26 +29,20 @@ 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.timestamps), "ride columns have different lengths")
+ 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.timestamps), "ride columns have different lengths")
for _, column := range [][]float64{r.heartRates, r.cadences, r.powers} {
if len(column) > 0 {
- kcore.Assert(len(r.distances) == len(column), "ride columns have different lengths")
+ kcore.Assert(len(r.Distances) == len(column), "ride columns have different lengths")
}
}
}
func (r Ride) Len() int {
- return len(r.distances)
+ 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) }
@@ -170,9 +162,11 @@ func FromColumnsWithMetrics(distances []float64, elevations []float64, coords []
func fromColumns(distances []float64, elevations []float64, coords []geodist.Coord, timestamps []time.Time, heartRates, cadences, powers []float64) Ride {
ride := Ride{
- distances: distances,
- elevations: elevations,
- coords: coords,
+ Route: climb.Route{
+ Distances: distances,
+ Elevations: elevations,
+ Coords: coords,
+ },
timestamps: timestamps,
heartRates: heartRates,
cadences: cadences,
@@ -229,81 +223,6 @@ func metricValue(point gpx.GPXPoint, name string) (*float64, error) {
return &value, nil
}
-const CotacolAlgorithmVersion = "v1"
-
-// cotacolSegmentLengthM is the fixed length of the elevation segments the
-// Cotacol score is computed over.
-const cotacolSegmentLengthM = 100
-
-func Cotacol(ride Ride) float64 {
- return difficultyScore(ride.distances, ride.elevations, 0, ride.Len()-1)
-}
-
-func difficultyScore(distances, elevations []float64, startIndex, endIndex int) float64 {
- if endIndex-startIndex < 1 {
- return 0
- }
- startDistance := distances[startIndex]
- lastDistance := distances[endIndex]
- if lastDistance <= startDistance {
- return 0
- }
- cotacol := 0.0
- i := startIndex
- for start := startDistance; start < lastDistance; start += cotacolSegmentLengthM {
- end := math.Min(start+cotacolSegmentLengthM, lastDistance)
- for i < endIndex && distances[i+1] <= start {
- i++
- }
- startElevation := interpolateElevation(distances, elevations, i, start)
- for i < endIndex && distances[i+1] < end {
- i++
- }
- endElevation := interpolateElevation(distances, elevations, i, end)
- slope := (endElevation - startElevation) / (end - start)
- if slope > 0 {
- cotacol += (end - start) / 1000 * (slope * 100) * (slope * 100)
- }
- }
- return cotacol
-}
-
-func interpolateElevation(distances, elevations []float64, i int, distance float64) float64 {
- if i+1 >= len(distances) {
- return elevations[i]
- }
- startDistance := distances[i]
- endDistance := distances[i+1]
- t := (distance - startDistance) / (endDistance - startDistance)
- return elevations[i] + t*(elevations[i+1]-elevations[i])
-}
-
-func (r *Ride) ClimbFromDist(startDist, endDist float64) Climb {
- start, end := 0, 0
- for i, distance := range r.distances {
- if start == 0 && distance >= startDist {
- start = i
- }
- if end == 0 && distance >= endDist {
- end = i
- break
- }
- }
- 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 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,
- }
+func (ride Ride) Cotacol() float64 {
+ return climb.Cotacol(ride.Distances, ride.Elevations)
}
diff --git a/ride/store.go b/ride/store.go
index 2d022ad..b906b42 100644
--- a/ride/store.go
+++ b/ride/store.go
@@ -7,6 +7,7 @@ import (
"strings"
"time"
+ "github.com/martinlehoux/biking_home/climb"
"github.com/martinlehoux/kagamigo/kcore"
)
@@ -57,8 +58,8 @@ func Save(db *sql.DB, r StoredRide) error {
if err != nil {
return fmt.Errorf("compute ride values for %q: %w", r.ExternalID, err)
}
- cotacolScore = Cotacol(parsed)
- cotacolAlgorithmVersion = CotacolAlgorithmVersion
+ cotacolScore = climb.Cotacol(parsed.Distances, parsed.Elevations)
+ cotacolAlgorithmVersion = climb.CotacolAlgorithmVersion
movingTimeS = int64(MovingTime(parsed) / time.Second)
movingTimeAlgorithmVersion = MovingTimeAlgorithmVersion
}
@@ -151,7 +152,7 @@ func ListSorted(db *sql.DB, column SortColumn, descending bool) ([]StoredRide, e
args = append(args, MovingTimeAlgorithmVersion)
} else if isComputedSort(column) {
query += "(cotacol_score IS NULL OR cotacol_algo_version IS NULL OR cotacol_algo_version <> ? OR distance_m <= 0) ASC, "
- args = append(args, CotacolAlgorithmVersion)
+ args = append(args, climb.CotacolAlgorithmVersion)
}
query += expression + " " + direction + ", id DESC"
rows, err := db.Query(query, args...)
diff --git a/ride/store_test.go b/ride/store_test.go
index 89c4830..1ee3c69 100644
--- a/ride/store_test.go
+++ b/ride/store_test.go
@@ -7,6 +7,7 @@ import (
"testing"
"time"
+ "github.com/martinlehoux/biking_home/climb"
"github.com/martinlehoux/biking_home/internal/dbtest"
"github.com/martinlehoux/kagamigo/kcore"
"github.com/stretchr/testify/assert"
@@ -53,7 +54,7 @@ func TestUpsertAndGet(t *testing.T) {
score, found := got.CotacolScore()
require.True(t, found)
assert.Greater(t, score, 0.0)
- assert.Equal(t, CotacolAlgorithmVersion, got.CotacolAlgorithmVersion())
+ assert.Equal(t, climb.CotacolAlgorithmVersion, got.CotacolAlgorithmVersion())
_, ok, err = GetByExternalID(db, "strava:9999")
require.NoError(t, err)
@@ -89,7 +90,7 @@ func TestSaveComputesCotacol(t *testing.T) {
score, found := got.CotacolScore()
require.True(t, found)
assert.Greater(t, score, 0.0)
- assert.Equal(t, CotacolAlgorithmVersion, got.CotacolAlgorithmVersion())
+ assert.Equal(t, climb.CotacolAlgorithmVersion, got.CotacolAlgorithmVersion())
}
func TestSaveComputesMovingTimeAndPreservesExternal(t *testing.T) {
@@ -190,7 +191,7 @@ func TestBackfill(t *testing.T) {
require.True(t, ok)
_, found := got.CotacolScore()
assert.True(t, found)
- assert.Equal(t, CotacolAlgorithmVersion, got.CotacolAlgorithmVersion())
+ assert.Equal(t, climb.CotacolAlgorithmVersion, got.CotacolAlgorithmVersion())
count, err = Backfill(db)
require.NoError(t, err)
@@ -277,7 +278,7 @@ func TestListSortedByCotacolKeepsStaleLast(t *testing.T) {
UPDATE rides
SET cotacol_score = CASE external_id WHEN ? THEN 4 ELSE 6 END,
cotacol_algo_version = ?
- `, first.ExternalID, CotacolAlgorithmVersion)
+ `, first.ExternalID, climb.CotacolAlgorithmVersion)
require.NoError(t, err)
items, err := ListSorted(db, SortCotacol, false)
@@ -307,7 +308,7 @@ func TestListSortedByCotacolPer100Km(t *testing.T) {
UPDATE rides
SET cotacol_score = CASE external_id WHEN ? THEN 4 ELSE 6 END,
cotacol_algo_version = ?
- `, first.ExternalID, CotacolAlgorithmVersion)
+ `, first.ExternalID, climb.CotacolAlgorithmVersion)
require.NoError(t, err)
items, err := ListSorted(db, SortCotacolKm, false)
diff --git a/rideanalysis/analysis.go b/rideanalysis/analysis.go
index 5e08e65..a9cf93d 100644
--- a/rideanalysis/analysis.go
+++ b/rideanalysis/analysis.go
@@ -3,6 +3,8 @@ package rideanalysis
import (
"slices"
+ "github.com/martinlehoux/biking_home/climb"
+ "github.com/martinlehoux/biking_home/core"
"github.com/martinlehoux/biking_home/mountain_pass"
"github.com/martinlehoux/biking_home/official_climb"
"github.com/martinlehoux/biking_home/ride"
@@ -21,7 +23,7 @@ type Result struct {
}
type AnalyzedClimb struct {
- Segment ride.Climb
+ Segment climb.Climb
Name string
Category string
DistanceM float64
@@ -29,6 +31,7 @@ type AnalyzedClimb struct {
Cotacol float64
OfficialClimbID int64
OfficialName string
+ Interval core.Interval
}
func Analyze(parsed ride.Ride, passes []mountain_pass.MountainPass, officialClimbs []official_climb.OfficialClimb, matchPolicy official_climb.MatchPolicy) Result {
@@ -37,45 +40,57 @@ func Analyze(parsed ride.Ride, passes []mountain_pass.MountainPass, officialClim
Crossings: make([]mountain_pass.Crossing, 0),
}
claimedClimbs := make([]AnalyzedClimb, 0)
- claimedRanges := make([]ride.Range, 0)
+ claimedRanges := make([]core.Interval, 0)
for _, official := range officialClimbs {
- 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))
+ for _, matched := range official_climb.MatchClimbs(parsed.Coords, official, matchPolicy.EndpointRadiusM) {
+ interval := core.NewInterval(matched.StartIndex, matched.EndIndex)
+ claimedRanges = append(claimedRanges, interval)
+ claimedClimbs = append(claimedClimbs, analyzedClimb(routeClimb(parsed.Route, interval), interval, official.Name, official.ID))
}
}
- slices.SortFunc(claimedRanges, func(a, b ride.Range) int { return a.Start - b.Start })
- for _, detectedClimb := range parsed.DetectClimbs(claimedRanges, ride.ClimbSlopeMinimum) {
- displayClimb := detectedClimb
- if matchedPass, ok := mountain_pass.MatchClimb(displayClimb, passes, passMatchRadiusM, passMatchElevationToleranceM); ok {
- displayClimb.Name = matchedPass.Name
+ slices.SortFunc(claimedRanges, func(a, b core.Interval) int { return a.Start - b.Start })
+ for _, detected := range climb.Detect(parsed.Route, claimedRanges, climb.ClimbSlopeMinimum) {
+ name := ""
+ if matchedPass, ok := mountain_pass.MatchClimb(detected.Climb, passes, passMatchRadiusM, passMatchElevationToleranceM); ok {
+ name = matchedPass.Name
}
- result.Climbs = append(result.Climbs, analyzedClimb(parsed, displayClimb, displayClimb.Name, 0))
+ interval := core.NewInterval(detected.Start, detected.Start+len(detected.Climb.Distances)-1)
+ result.Climbs = append(result.Climbs, analyzedClimb(detected.Climb, interval, name, 0))
}
climbs := make([]AnalyzedClimb, 0, len(claimedClimbs)+len(result.Climbs))
climbs = append(climbs, claimedClimbs...)
climbs = append(climbs, result.Climbs...)
slices.SortFunc(climbs, func(a, b AnalyzedClimb) int {
- return a.Segment.StartIndex() - b.Segment.StartIndex()
+ return a.Interval.Start - b.Interval.Start
})
result.Climbs = climbs
- for _, crossing := range mountain_pass.DetectCrossings(parsed.Coords(), parsed.Distances(), parsed.Elevations(), passes, passCrossingRadiusM, passCrossingElevationToleranceM) {
+ for _, crossing := range mountain_pass.DetectCrossings(parsed.Coords, parsed.Distances, parsed.Elevations, passes, passCrossingRadiusM, passCrossingElevationToleranceM) {
result.Crossings = append(result.Crossings, crossing)
}
return result
}
-func analyzedClimb(parsed ride.Ride, segment ride.Climb, name string, officialID int64) AnalyzedClimb {
+// routeClimb returns the climb segment of route covered by interval.
+func routeClimb(route climb.Route, interval core.Interval) climb.Climb {
+ return climb.Climb{Route: climb.Route{
+ Distances: route.Distances[interval.Start : interval.End+1],
+ Elevations: route.Elevations[interval.Start : interval.End+1],
+ Coords: route.Coords[interval.Start : interval.End+1],
+ }}
+}
+
+func analyzedClimb(segment climb.Climb, interval core.Interval, name string, officialID int64) AnalyzedClimb {
cotacol := segment.DifficultyScore()
return AnalyzedClimb{
Segment: segment,
Name: name,
- Category: ride.CategoryOf(cotacol).String(),
- DistanceM: segment.EndDistanceM() - segment.StartDistanceM(),
- SlopePercent: ride.Slope(parsed.Distances(), parsed.Elevations(), segment.StartIndex(), segment.EndIndex()) * 100,
+ Category: climb.CategoryOf(cotacol).String(),
+ DistanceM: segment.Distances[len(segment.Distances)-1] - segment.Distances[0],
+ SlopePercent: climb.Slope(segment.Distances, segment.Elevations) * 100,
Cotacol: cotacol,
OfficialClimbID: officialID,
OfficialName: officialClimbNameForID(name, officialID),
+ Interval: interval,
}
}
diff --git a/web/ride-filters.go b/web/ride-filters.go
index 0bb97d8..8535756 100644
--- a/web/ride-filters.go
+++ b/web/ride-filters.go
@@ -6,6 +6,7 @@ import (
"strconv"
"time"
+ "github.com/martinlehoux/biking_home/climb"
"github.com/martinlehoux/biking_home/ride"
)
@@ -417,7 +418,7 @@ func rideMatchesFilters(item ride.StoredRide, filters RideFilters) bool {
}
if filters.CotacolMinPoints != nil || filters.CotacolMaxPoints != nil {
cotacol, ready := item.CotacolScore()
- if !ready || item.CotacolAlgorithmVersion() != ride.CotacolAlgorithmVersion || item.DistanceM <= 0 {
+ if !ready || item.CotacolAlgorithmVersion() != climb.CotacolAlgorithmVersion || item.DistanceM <= 0 {
return false
}
if filters.CotacolMinPoints != nil && cotacol < float64(*filters.CotacolMinPoints) {
diff --git a/web/ride-filters_test.go b/web/ride-filters_test.go
index e4efc02..84c6417 100644
--- a/web/ride-filters_test.go
+++ b/web/ride-filters_test.go
@@ -8,6 +8,7 @@ import (
"testing"
"time"
+ "github.com/martinlehoux/biking_home/climb"
"github.com/martinlehoux/biking_home/ride"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
@@ -209,7 +210,7 @@ func TestFilterRidesByCotacolExcludesUnavailableScores(t *testing.T) {
WHEN ? THEN 'old'
END
WHERE external_id IN (?, ?)
- `, current.ExternalID, stale.ExternalID, current.ExternalID, ride.CotacolAlgorithmVersion, stale.ExternalID, current.ExternalID, stale.ExternalID)
+ `, current.ExternalID, stale.ExternalID, current.ExternalID, climb.CotacolAlgorithmVersion, stale.ExternalID, current.ExternalID, stale.ExternalID)
require.NoError(t, err)
items, err := ride.List(db)
require.NoError(t, err)
diff --git a/web/rides.go b/web/rides.go
index 71ab3fd..d3f5634 100644
--- a/web/rides.go
+++ b/web/rides.go
@@ -6,6 +6,7 @@ import (
"net/http"
"strconv"
+ "github.com/martinlehoux/biking_home/climb"
"github.com/martinlehoux/biking_home/mountain_pass"
"github.com/martinlehoux/biking_home/official_climb"
"github.com/martinlehoux/biking_home/ride"
@@ -65,7 +66,7 @@ func (s *Server) handleRide(w http.ResponseWriter, r *http.Request) {
}), w)
return
}
- passes, err := mountain_pass.LoadMountainPassesAroundRide(s.db, parsed.Coords(), rideDetailMountainPassMarginM)
+ passes, err := mountain_pass.LoadMountainPassesAroundRide(s.db, parsed.Coords, rideDetailMountainPassMarginM)
if err != nil {
slog.Warn("Failed to load nearby mountain passes for ride detail", "ride_id", id, "error", err)
}
@@ -144,7 +145,7 @@ func (s *Server) handleCreateOfficialClimb(w http.ResponseWriter, r *http.Reques
http.Error(w, "the recorded route is unavailable", http.StatusBadRequest)
return
}
- bounds := parsed.Coords()
+ bounds := parsed.Coords
if startIndex >= endIndex || endIndex >= len(bounds) {
http.Error(w, "the official climb boundaries must be ordered route points", http.StatusBadRequest)
return
@@ -192,7 +193,7 @@ func buildRideViews(items []ride.StoredRide) []RideView {
func buildRideView(item ride.StoredRide) RideView {
view := RideView{Ride: item, Cotacol: "-", CotacolPer100Km: "-", MovingTime: formatDuration(item.EffectiveMovingTimeS())}
cotacol, ready := item.CotacolScore()
- if ready && item.CotacolAlgorithmVersion() == ride.CotacolAlgorithmVersion && item.DistanceM > 0 {
+ if ready && item.CotacolAlgorithmVersion() == climb.CotacolAlgorithmVersion && item.DistanceM > 0 {
view.Cotacol = formatCotacol(cotacol)
view.CotacolPer100Km = formatCotacolPer100Km(cotacol, item.DistanceM)
}
diff --git a/web/server_test.go b/web/server_test.go
index 6a1c13f..a60d742 100644
--- a/web/server_test.go
+++ b/web/server_test.go
@@ -15,6 +15,7 @@ import (
"time"
"github.com/jftuga/geodist"
+ "github.com/martinlehoux/biking_home/climb"
"github.com/martinlehoux/biking_home/config"
"github.com/martinlehoux/biking_home/internal/dbtest"
"github.com/martinlehoux/biking_home/internal/ridetest"
@@ -225,8 +226,8 @@ func TestBuildRideProfileIncludesOfficialClimbMatch(t *testing.T) {
profile := buildRideProfile(parsed, nil, []official_climb.OfficialClimb{{
ID: 42,
Name: "Col de Test",
- StartCoord: parsed.Coords()[0],
- EndCoord: parsed.Coords()[1],
+ StartCoord: parsed.Coords[0],
+ EndCoord: parsed.Coords[1],
}}, official_climb.DefaultMatchPolicy())
require.Len(t, profile.Climbs, 1)
@@ -242,8 +243,8 @@ func TestBuildRideProfileUsesOfficialClimbBoundaries(t *testing.T) {
profile := buildRideProfile(parsed, nil, []official_climb.OfficialClimb{{
ID: 42,
Name: "Col de Test",
- StartCoord: parsed.Coords()[0],
- EndCoord: parsed.Coords()[2],
+ StartCoord: parsed.Coords[0],
+ EndCoord: parsed.Coords[2],
}}, official_climb.DefaultMatchPolicy())
require.Len(t, profile.Climbs, 1)
@@ -370,7 +371,7 @@ func TestHandlerRendersAnalyticsChart(t *testing.T) {
SET cotacol_score = CASE external_id WHEN ? THEN 20 ELSE 120 END,
cotacol_algo_version = ?,
average_speed_mps = CASE external_id WHEN ? THEN 7.0 ELSE 4.0 END
- `, first.ExternalID, ride.CotacolAlgorithmVersion, second.ExternalID)
+ `, first.ExternalID, climb.CotacolAlgorithmVersion, second.ExternalID)
require.NoError(t, err)
req := httptest.NewRequest(http.MethodGet, "/analytics", nil)
@@ -416,7 +417,7 @@ func TestHandlerFiltersAnalyticsByDistance(t *testing.T) {
SET cotacol_score = CASE external_id WHEN ? THEN 20 ELSE 120 END,
cotacol_algo_version = ?,
average_speed_mps = CASE external_id WHEN ? THEN 7.0 ELSE 5.0 END
- `, short.ExternalID, ride.CotacolAlgorithmVersion, long.ExternalID)
+ `, short.ExternalID, climb.CotacolAlgorithmVersion, long.ExternalID)
require.NoError(t, err)
req := httptest.NewRequest(http.MethodGet, "/analytics?distance_min=60", nil)
diff --git a/web/views.go b/web/views.go
index b3cab8b..4f769cc 100644
--- a/web/views.go
+++ b/web/views.go
@@ -5,6 +5,7 @@ import (
"net/url"
"time"
+ "github.com/martinlehoux/biking_home/climb"
"github.com/martinlehoux/biking_home/mountain_pass"
"github.com/martinlehoux/biking_home/official_climb"
"github.com/martinlehoux/biking_home/ride"
@@ -47,7 +48,7 @@ func buildAnalyticsPoints(items []ride.StoredRide) []AnalyticsPoint {
points := make([]AnalyticsPoint, 0, len(items))
for _, item := range items {
cotacol, ready := item.CotacolScore()
- if !ready || item.CotacolAlgorithmVersion() != ride.CotacolAlgorithmVersion || item.DistanceM <= 0 {
+ if !ready || item.CotacolAlgorithmVersion() != climb.CotacolAlgorithmVersion || item.DistanceM <= 0 {
continue
}
points = append(points, AnalyticsPoint{
@@ -286,7 +287,7 @@ func officialClimbCreateURL(id int64) string {
}
func buildRideDetailView(item ride.StoredRide, parsed ride.Ride, passes []mountain_pass.MountainPass, officialClimbs []official_climb.OfficialClimb, matchPolicy official_climb.MatchPolicy) RideDetailView {
- coords := parsed.Coords()
+ coords := parsed.Coords
coordinates := make([][]float64, len(coords))
for i, coordinate := range coords {
coordinates[i] = []float64{coordinate.Lon, coordinate.Lat}
@@ -327,9 +328,9 @@ func buildRideMapPasses(passes []mountain_pass.MountainPass) []RideMapPass {
}
func buildRideProfile(parsed ride.Ride, passes []mountain_pass.MountainPass, officialClimbs []official_climb.OfficialClimb, matchPolicy official_climb.MatchPolicy) RideProfile {
- coords := parsed.Coords()
- distances := parsed.Distances()
- elevations := parsed.Elevations()
+ coords := parsed.Coords
+ distances := parsed.Distances
+ elevations := parsed.Elevations
profile := RideProfile{
Points: make([]RideProfilePoint, len(coords)),
Climbs: make([]RideProfileClimb, 0),
@@ -346,12 +347,13 @@ func buildRideProfile(parsed ride.Ride, passes []mountain_pass.MountainPass, off
analysis := rideanalysis.Analyze(parsed, passes, officialClimbs, matchPolicy)
for _, analyzedClimb := range analysis.Climbs {
segment := analyzedClimb.Segment
+ topIndex, topElevationM := segment.Top()
climb := RideProfileClimb{
Index: len(profile.Climbs),
- StartKm: segment.StartDistanceM() / 1000,
- EndKm: segment.EndDistanceM() / 1000,
- TopKm: segment.TopDistanceM() / 1000,
- TopElevationM: segment.TopElevationM(),
+ StartKm: segment.Distances[0] / 1000,
+ EndKm: segment.Distances[len(segment.Distances)-1] / 1000,
+ TopKm: segment.Distances[topIndex] / 1000,
+ TopElevationM: topElevationM,
Name: analyzedClimb.Name,
Category: analyzedClimb.Category,
DistanceKm: analyzedClimb.DistanceM / 1000,
@@ -359,8 +361,8 @@ func buildRideProfile(parsed ride.Ride, passes []mountain_pass.MountainPass, off
Cotacol: analyzedClimb.Cotacol,
OfficialClimbID: analyzedClimb.OfficialClimbID,
OfficialName: analyzedClimb.OfficialName,
- StartIndex: segment.StartIndex(),
- EndIndex: segment.EndIndex(),
+ StartIndex: analyzedClimb.Interval.Start,
+ EndIndex: analyzedClimb.Interval.End,
}
profile.Climbs = append(profile.Climbs, climb)
}