summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--README.md15
-rw-r--r--go.mod16
-rw-r--r--go.sum24
-rw-r--r--main.go209
-rw-r--r--ride/climb.go113
-rw-r--r--ride/climb_test.go82
-rw-r--r--ride/ride.go46
7 files changed, 261 insertions, 244 deletions
diff --git a/README.md b/README.md
index 4cab6d3..adc3787 100644
--- a/README.md
+++ b/README.md
@@ -35,17 +35,4 @@
**[Pogacar 21 july 2022](https://www.strava.com/activities/7505784085)**
-**Expected**
-
-| From | To |
-| ------- | ------- |
-| 1.0km | 5.7km |
-| 44.0km | 45.2km |
-| 60.1km | 76.3km |
-| 83.8km | 86.3km |
-| 99.0km | 109.5km |
-| 128.2km | 141.8km |
-
-- add min slope to prevent long false up ?
-- check score of 60km-76km vs 32km-76km
- - as expected, the score is better but not found
+- 43.0-45.5 ?
diff --git a/go.mod b/go.mod
index cfb39fd..353d1ed 100644
--- a/go.mod
+++ b/go.mod
@@ -3,22 +3,24 @@ module github.com/martinlehoux/biking_home
go 1.22
require (
- github.com/go-echarts/go-echarts/v2 v2.4.1
- github.com/martinlehoux/kagamigo v0.4.0
- github.com/olekukonko/tablewriter v0.0.5
+ github.com/martinlehoux/kagamigo v0.4.1
+ github.com/stretchr/testify v1.8.4
github.com/tkrajina/gpxgo v1.4.0
)
require (
github.com/BurntSushi/toml v1.4.0 // indirect
- github.com/a-h/templ v0.2.707 // indirect
+ github.com/a-h/templ v0.2.747 // indirect
+ github.com/davecgh/go-spew v1.1.1 // indirect
github.com/gofrs/uuid v4.4.0+incompatible // indirect
github.com/kataras/i18n v0.0.8 // indirect
+ github.com/kr/pretty v0.1.0 // indirect
github.com/kr/text v0.2.0 // indirect
- github.com/mattn/go-runewidth v0.0.9 // indirect
- golang.org/x/exp v0.0.0-20240604190554-fc45aab8b7f8 // indirect
- golang.org/x/net v0.26.0 // indirect
+ github.com/pmezard/go-difflib v1.0.0 // indirect
+ golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 // indirect
+ golang.org/x/net v0.27.0 // indirect
golang.org/x/text v0.16.0 // indirect
+ gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 // indirect
gopkg.in/ini.v1 v1.67.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)
diff --git a/go.sum b/go.sum
index a232fe1..293e404 100644
--- a/go.sum
+++ b/go.sum
@@ -1,13 +1,11 @@
github.com/BurntSushi/toml v1.4.0 h1:kuoIxZQy2WRRk1pttg9asf+WVv6tWQuBNVmK8+nqPr0=
github.com/BurntSushi/toml v1.4.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
-github.com/a-h/templ v0.2.707 h1:T1Gkd2ugbRglZ9rYw/VBchWOSZVKmetDbBkm4YubM7U=
-github.com/a-h/templ v0.2.707/go.mod h1:5cqsugkq9IerRNucNsI4DEamdHPsoGMQy99DzydLhM8=
+github.com/a-h/templ v0.2.747 h1:D0dQ2lxC3W7Dxl6fxQ/1zZHBQslSkTSvl5FxP/CfdKg=
+github.com/a-h/templ v0.2.747/go.mod h1:69ObQIbrcuwPCU32ohNaWce3Cb7qM5GMiqN1K+2yop4=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
-github.com/go-echarts/go-echarts/v2 v2.4.1 h1:imBFGngJ9zv/2zJVjK3k0uLL+LzyPDgzeV7MWzxH0rs=
-github.com/go-echarts/go-echarts/v2 v2.4.1/go.mod h1:56YlvzhW/a+du15f3S2qUGNDfKnFOeJSThBIrVFHDtI=
github.com/gofrs/uuid v4.4.0+incompatible h1:3qXRTX8/NbyulANqlc0lchS1gqAVxRgsuW1YrTJupqA=
github.com/gofrs/uuid v4.4.0+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRxnplIgP/c0N/04lM=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
@@ -16,14 +14,12 @@ github.com/kataras/i18n v0.0.8 h1:thiDRqq4fN2sQOK5CwR5Yh1yT7swP6B3wnadILhqjhk=
github.com/kataras/i18n v0.0.8/go.mod h1:M/yRAqQ3Y7z2oSpotxG/+nPrgsJLas6t0kEJfIJk98E=
github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
+github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
+github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
-github.com/martinlehoux/kagamigo v0.4.0 h1:v9iHjh1a7vJP13iGnGR6G5OIhdgU9j/YICjvkOqGcsA=
-github.com/martinlehoux/kagamigo v0.4.0/go.mod h1:CDkN/Kx8KQSU0kC/ISfq2gYIhVbddHD3E8r3uAlnnMI=
-github.com/mattn/go-runewidth v0.0.9 h1:Lm995f3rfxdpd6TSmuVCHVb/QhupuXlYr8sCI/QdE+0=
-github.com/mattn/go-runewidth v0.0.9/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI=
-github.com/olekukonko/tablewriter v0.0.5 h1:P2Ga83D34wi1o9J6Wh1mRuqd4mF/x/lgBS7N7AbDhec=
-github.com/olekukonko/tablewriter v0.0.5/go.mod h1:hPp6KlRPjbx+hW8ykQs1w3UBbZlj6HuIJcUGPhkA7kY=
+github.com/martinlehoux/kagamigo v0.4.1 h1:jI4TXyQMdm05H/Pd5RE7pfoMHq9PqdOF21oWAZOxsJ8=
+github.com/martinlehoux/kagamigo v0.4.1/go.mod h1:CDkN/Kx8KQSU0kC/ISfq2gYIhVbddHD3E8r3uAlnnMI=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
@@ -32,11 +28,11 @@ github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcU
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/tkrajina/gpxgo v1.4.0 h1:cSD5uSwy3VZuNFieTEZLyRnuIwhonQEkGPkPGW4XNag=
github.com/tkrajina/gpxgo v1.4.0/go.mod h1:BXSMfUAvKiEhMEXAFM2NvNsbjsSvp394mOvdcNjettg=
-golang.org/x/exp v0.0.0-20240604190554-fc45aab8b7f8 h1:LoYXNGAShUG3m/ehNk4iFctuhGX/+R1ZpfJ4/ia80JM=
-golang.org/x/exp v0.0.0-20240604190554-fc45aab8b7f8/go.mod h1:jj3sYF3dwk5D+ghuXyeI3r5MFf+NT2An6/9dOA95KSI=
+golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 h1:2dVuKD2vS7b0QIHQbpyTISPd0LeHDbnYEryqj5Q1ug8=
+golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56/go.mod h1:M4RDyNAINzryxdtnbRXRL/OHtkFuWGRjvuhBJpk2IlY=
golang.org/x/net v0.0.0-20210614182718-04defd469f4e/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
-golang.org/x/net v0.26.0 h1:soB7SVo0PWrY4vPW/+ay0jKDNScG2X9wFeYlXIvJsOQ=
-golang.org/x/net v0.26.0/go.mod h1:5YKkiSynbBIh3p6iOc/vibscux0x38BZDkn8sCUPxHE=
+golang.org/x/net v0.27.0 h1:5K3Njcw06/l2y9vpGCSdcxWOYHOUk3dVNGDXN+FvAys=
+golang.org/x/net v0.27.0/go.mod h1:dDi0PyhWNoiUOrAS8uXv/vnScO4wnHQO4mj9fn/RytE=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
diff --git a/main.go b/main.go
index b6cb21a..468737c 100644
--- a/main.go
+++ b/main.go
@@ -1,167 +1,13 @@
package main
import (
- "errors"
"flag"
- "fmt"
- "io"
- "math"
"os"
"runtime/pprof"
- "slices"
- "github.com/go-echarts/go-echarts/v2/charts"
- "github.com/go-echarts/go-echarts/v2/opts"
"github.com/martinlehoux/kagamigo/kcore"
- "github.com/olekukonko/tablewriter"
- "github.com/tkrajina/gpxgo/gpx"
)
-var ErrAssert = errors.New("assertion error")
-
-func Assert(cond bool, msg string) {
- if !cond {
- err := ErrAssert
- if msg != "" {
- err = fmt.Errorf("%w: %s", err, msg)
- }
- panic(err)
- }
-}
-
-func Score(points []Point, start int, end int) float64 {
- Assert(end > start, "no points for score")
- distance := points[end].distance - points[start].distance
- if distance == 0 {
- return 0
- }
- // TODO: Expect NullableFloat64
- dElevation := points[end].Elevation.Value() - points[start].Elevation.Value()
-
- 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"
- }
-}
-
-type Climb struct {
- start int
- end int
-}
-
-type Point struct {
- gpx.GPXPoint
- distance float64
-}
-
-type Ride struct {
- points []Point
-}
-
-func ParseGPX(r io.Reader) Ride {
- content, err := gpx.Parse(r)
- kcore.Expect(err, "failed to parse GPX")
- 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])
- }
- Assert(i == 0 || distance > 0, "zero distance")
- points[i] = Point{p, distance}
- }
- return Ride{points}
-}
-
-func BestUpHill(points []Point, start int, end int) Climb {
- 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
- }
- }
- bestEnd := end
- for i := end; i > bestStart; i-- {
- score := Score(points, bestStart, i)
- if score > bestScore {
- bestEnd = i
- bestScore = score
- }
- }
- return Climb{bestStart, bestEnd}
-}
-
-func BestDownHill(points []Point, start int, end int) Climb {
- Assert(len(points) > 0, "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
- }
- }
- bestEnd := end
- for i := end; i > bestStart; i-- {
- score := Score(points, bestStart, i)
- if score < bestScore {
- bestEnd = i
- bestScore = score
- }
- }
- return Climb{bestStart, bestEnd}
-}
-
-func FindAllClimbs(points []Point, start int, end int) []Climb {
- fmt.Printf("Searching climbs between %.1fkm and %.1fkm\n", points[start].distance/1000, points[end].distance/1000)
- climbs := []Climb{}
- climb := BestUpHill(points, start, end)
- descent := BestDownHill(points, start, end)
- Assert(climb.start < climb.end, "empty climb")
-
- if Score(points, climb.start, climb.end) < 35 {
- if Score(points, descent.start, descent.end) < -35 {
- if descent.start > start {
- climbs = append(climbs, FindAllClimbs(points, start, descent.start)...)
- }
- if end > descent.end {
- climbs = append(climbs, FindAllClimbs(points, descent.end, end)...)
- }
- }
- } else {
- climbs = append(climbs, climb)
- fmt.Printf("Found climb between %.1fkm and %.1fkm\n", points[climb.start].distance/1000, points[climb.end].distance/1000)
- if climb.start > start {
- climbs = append(climbs, FindAllClimbs(points, start, climb.start)...)
- }
- if end > climb.end {
- climbs = append(climbs, FindAllClimbs(points, climb.end, end)...)
- }
- }
-
- return climbs
-}
-
var cpuprofile = flag.String("cpuprofile", "", "write cpu profile to file")
func main() {
@@ -172,59 +18,4 @@ func main() {
pprof.StartCPUProfile(f)
defer pprof.StopCPUProfile()
}
- gpxFile, err := os.Open("examples/2022-07-21.Pogacar.gpx")
- kcore.Expect(err, "failed to open GPX file")
- ride := ParseGPX(gpxFile)
- Assert(len(ride.points) > 0, "no points in ride")
- fmt.Printf("Ride loaded:\t%.1fkm\t%d points\n", ride.points[len(ride.points)-1].distance/1000, len(ride.points))
- climbs := FindAllClimbs(ride.points, 0, len(ride.points)-1)
- slices.SortFunc(climbs, func(i, j Climb) int { return i.start - j.start })
- expectedClimb := Climb{start: 0, end: 0}
- for i, p := range ride.points {
- if expectedClimb.start == 0 && p.distance > 60_000 {
- expectedClimb.start = i
- }
- if expectedClimb.end == 0 && p.distance > 76_400 {
- expectedClimb.end = i
- break
- }
- }
- println("Expected climb", expectedClimb.start, expectedClimb.end)
-
- viewer := Viewer(ride)
- table := tablewriter.NewWriter(os.Stdout)
- table.SetHeader([]string{"Score", "Distance", "Category", "Slope", "From", "To", "Point span"})
- for _, climb := range climbs {
- table.Append([]string{viewer.Score(climb), viewer.Distance(climb), Category(Score(ride.points, climb.start, climb.end)), viewer.Slope(climb), fmt.Sprintf("%.1fkm", ride.points[climb.start].distance/1000), fmt.Sprintf("%.1fkm", ride.points[climb.end].distance/1000), fmt.Sprintf("%d", climb.end-climb.start)})
- }
- table.Append([]string{viewer.Score(expectedClimb), viewer.Distance(expectedClimb), Category(Score(ride.points, expectedClimb.start, expectedClimb.end)), viewer.Slope(expectedClimb), fmt.Sprintf("%.1fkm", ride.points[expectedClimb.start].distance/1000), fmt.Sprintf("%.1fkm", ride.points[expectedClimb.end].distance/1000), fmt.Sprintf("%d", expectedClimb.end-expectedClimb.start)})
- table.Render()
-
- charts.NewLine()
- scatter := charts.NewLine()
- values := make([]opts.LineData, len(ride.points))
- for i, p := range ride.points {
- values[i] = opts.LineData{Value: []any{i, p.distance}}
- }
- scatter.AddSeries("distance", values)
- f, err := os.Create("scatter.html")
- kcore.Expect(err, "failed to create scatter plot")
- defer f.Close()
- scatter.Render(f)
-}
-
-type Viewer struct {
- points []Point
-}
-
-func (v *Viewer) Score(climb Climb) string {
- return fmt.Sprintf("%.0f", Score(v.points, climb.start, climb.end))
-}
-
-func (v *Viewer) Distance(climb Climb) string {
- return fmt.Sprintf("%.1fkm", (v.points[climb.end].distance-v.points[climb.start].distance)/1000)
-}
-
-func (v *Viewer) Slope(climb Climb) string {
- return fmt.Sprintf("%.1f%%", (v.points[climb.end].Elevation.Value()-v.points[climb.start].Elevation.Value())/(v.points[climb.end].distance-v.points[climb.start].distance)*100)
}
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))
+}