summaryrefslogtreecommitdiff
path: root/ride
diff options
context:
space:
mode:
Diffstat (limited to 'ride')
-rw-r--r--ride/climb.go113
-rw-r--r--ride/climb_test.go82
-rw-r--r--ride/ride.go46
3 files changed, 241 insertions, 0 deletions
diff --git a/ride/climb.go b/ride/climb.go
new file mode 100644
index 0000000..a337a15
--- /dev/null
+++ b/ride/climb.go
@@ -0,0 +1,113 @@
+package ride
+
+import (
+ "fmt"
+ "math"
+ "slices"
+
+ "github.com/martinlehoux/kagamigo/kcore"
+)
+
+const ClimbDistanceMinimum = 500
+
+type Climb struct {
+ start int
+ end int
+}
+
+type Point struct {
+ distance float64
+ elevation float64
+}
+
+func NewPoint(distance float64, elevation float64) Point {
+ return Point{
+ distance: distance,
+ elevation: elevation,
+ }
+}
+
+func Slope(start, end Point) float64 {
+ return (end.elevation - start.elevation) / (end.distance - start.distance)
+}
+
+func Score(points []Point, start int, end int) float64 {
+ kcore.Assert(end > start, "no points for score")
+ distance := points[end].distance - points[start].distance
+ if distance == 0 {
+ return 0
+ }
+ dElevation := points[end].elevation - points[start].elevation
+
+ return math.Abs(dElevation) * dElevation / distance * 100.0 * 100.0 / 1000.0
+}
+
+func Category(score float64) string {
+ switch {
+ case score < 35:
+ return "NO"
+ case score < 80:
+ return "Cat 4"
+ case score < 180:
+ return "Cat 3"
+ case score < 250:
+ return "Cat 2"
+ case score < 600:
+ return "Cat 1"
+ default:
+ return "HC"
+ }
+}
+
+func bestClimbUntilEnd(points []Point, start int, end int) Climb {
+ kcore.Assert(end > start, "empty points")
+
+ bestScore := Score(points, start, end)
+ bestStart := start
+ for i := start; i < end; i++ {
+ score := Score(points, i, end)
+ if score > bestScore {
+ bestStart = i
+ bestScore = score
+ }
+ }
+ climb := Climb{bestStart, end}
+
+ kcore.Assert(climb.start < climb.end, "empty climb")
+ return climb
+}
+
+func climbsBetween(points []Point, start int, end int) []Climb {
+ climbs := []Climb{}
+ if points[end].distance-points[start].distance < ClimbDistanceMinimum {
+ return climbs
+ }
+ fmt.Printf("Searching climbs between %.1fkm and %.1fkm\n", points[start].distance/1000, points[end].distance/1000)
+ highest := start
+ for i := start; i < end; i++ {
+ if points[i].elevation > points[highest].elevation {
+ highest = i
+ }
+ }
+ // TODO: Use descent to reduce recursion
+ if highest == start {
+ return climbsBetween(points, start+1, end)
+ }
+ climb := bestClimbUntilEnd(points, start, highest)
+ if Score(points, climb.start, climb.end) >= 35 {
+ fmt.Printf("Found climb between %.1fkm and %.1fkm\n", points[climb.start].distance/1000, points[climb.end].distance/1000)
+ climbs = append(climbs, climb)
+ }
+ climbs = append(climbs, climbsBetween(points, start, climb.start)...)
+ climbs = append(climbs, climbsBetween(points, climb.end, end)...)
+
+ return climbs
+}
+
+func (ride *Ride) AllClimbs() []Climb {
+ climbs := climbsBetween(ride.points, 0, len(ride.points)-1)
+ slices.SortFunc(climbs, climbCmpStart)
+ return climbs
+}
+
+func climbCmpStart(a, b Climb) int { return a.start - b.start }
diff --git a/ride/climb_test.go b/ride/climb_test.go
new file mode 100644
index 0000000..5acc3c4
--- /dev/null
+++ b/ride/climb_test.go
@@ -0,0 +1,82 @@
+package ride_test
+
+import (
+ "strconv"
+ "strings"
+ "testing"
+
+ "github.com/martinlehoux/biking_home/ride"
+ "github.com/martinlehoux/kagamigo/kcore"
+ "github.com/stretchr/testify/assert"
+ "github.com/tkrajina/gpxgo/gpx"
+)
+
+type RideBuilderSection struct {
+ slope float64
+ distance float64
+}
+
+type RideBuilder struct {
+ precision float64
+ sections []RideBuilderSection
+}
+
+// Example: 13.4km at 7.5%
+func (b RideBuilder) WithSection(input string) RideBuilder {
+ inputs := strings.Split(input, " ")
+ kcore.Assert(strings.HasSuffix(inputs[0], "km"), "wrong unit")
+ distanceKm, err := strconv.ParseFloat(strings.TrimSuffix(inputs[0], "km"), 64)
+ kcore.Expect(err, "faile to parse kms")
+ kcore.Assert(strings.HasSuffix(inputs[2], "%"), "wrong unit")
+ slopePercent, err := strconv.ParseFloat(strings.TrimSuffix(inputs[2], "%"), 64)
+ kcore.Expect(err, "failed to parse %")
+
+ b.sections = append(b.sections, RideBuilderSection{
+ slope: slopePercent / 100,
+ distance: distanceKm * 1000,
+ })
+
+ return b
+}
+
+func (b RideBuilder) Build() ride.Ride {
+ points := []ride.Point{ride.NewPoint(0, 0)}
+ distance := 0.0
+ elevation := 0.0
+ for _, section := range b.sections {
+ curSecDist := 0.0
+ for curSecDist < section.distance {
+ curSecDist += b.precision
+ elevation += b.precision * section.slope
+ points = append(points, ride.NewPoint(
+ distance+curSecDist,
+ elevation,
+ ))
+ }
+ distance += curSecDist
+ }
+ return ride.FromPoints(points)
+}
+
+func TestClimbThenFalseFlat(t *testing.T) {
+ r := RideBuilder{precision: 100}.WithSection("2km at 7%").WithSection("10km at 1%").Build()
+ climbs := r.AllClimbs()
+
+ assert.Len(t, climbs, 2)
+ assert.Equal(t, "0.0km-11.9km: 11.9km at 1.8% (40 pts - Cat 4)", r.String(climbs[0]))
+ assert.Equal(t, "", r.String(climbs[1]))
+}
+
+func TestPogacar20220721(t *testing.T) {
+ gpxContent, err := gpx.ParseFile("../examples/2022-07-21.Pogacar.gpx")
+ assert.NoError(t, err)
+ r := ride.FromGPX(gpxContent)
+ climbs := r.AllClimbs()
+
+ assert.Len(t, climbs, 5)
+ assert.Equal(t, "0.6km-5.6km: 5.0km at 3.5% (62 pts - Cat 4)", r.String(climbs[0]))
+ assert.Equal(t, "60.0km-76.4km: 16.4km at 7.2% (854 pts - HC)", r.String(climbs[1]))
+ assert.Equal(t, "83.8km-85.9km: 2.1km at 5.4% (59 pts - Cat 4)", r.String(climbs[2]))
+ assert.Equal(t, "99.2km-109.3km: 10.2km at 8.4% (710 pts - HC)", r.String(climbs[3]))
+ assert.Equal(t, "128.6km-142.2km: 13.6km at 7.8% (832 pts - HC)", r.String(climbs[4]))
+}
diff --git a/ride/ride.go b/ride/ride.go
new file mode 100644
index 0000000..8206793
--- /dev/null
+++ b/ride/ride.go
@@ -0,0 +1,46 @@
+package ride
+
+import (
+ "fmt"
+
+ "github.com/martinlehoux/kagamigo/kcore"
+ "github.com/tkrajina/gpxgo/gpx"
+)
+
+type Ride struct {
+ points []Point
+}
+
+func (r *Ride) check() {
+ kcore.Assert(len(r.points) > 0, "no points in ride")
+}
+
+func FromGPX(content *gpx.GPX) Ride {
+ segment := content.Tracks[0].Segments[0]
+ points := make([]Point, len(segment.Points))
+ distance := 0.0
+ for i, p := range segment.Points {
+ if i != 0 {
+ distance += p.Distance2D(&segment.Points[i-1])
+ }
+ kcore.Assert(i == 0 || distance > 0, "zero distance")
+ kcore.Assert(p.Elevation.NotNull(), "points without elevation")
+ points[i] = Point{distance: distance, elevation: p.Elevation.Value()}
+ }
+ ride := Ride{points}
+ ride.check()
+ return ride
+}
+
+func FromPoints(points []Point) Ride {
+ ride := Ride{points}
+ ride.check()
+ return ride
+}
+
+func (r *Ride) String(climb Climb) string {
+ start := r.points[climb.start]
+ end := r.points[climb.end]
+ score := Score(r.points, climb.start, climb.end)
+ return fmt.Sprintf("%.1fkm-%.1fkm: %.1fkm at %.1f%% (%d pts - %s)", start.distance/1000, end.distance/1000, (end.distance-start.distance)/1000, Slope(start, end)*100, int(score), Category(score))
+}