diff options
| author | Martin Kagamino Lehoux <martin@lehoux.net> | 2024-07-28 15:52:12 +0200 |
|---|---|---|
| committer | Martin Kagamino Lehoux <martin@lehoux.net> | 2024-07-28 15:52:12 +0200 |
| commit | 20043af3c289052e239773257cbbd961e4397394 (patch) | |
| tree | 2f39f251961bbbebf0a86ef0481759e0401be436 | |
| parent | 45844ae231b7afe68a1cd5b7c0d09fd77e806d0f (diff) | |
perf: Precompute distance
| -rw-r--r-- | README.md | 8 | ||||
| -rw-r--r-- | go.mod | 16 | ||||
| -rw-r--r-- | go.sum | 40 | ||||
| -rw-r--r-- | main.go | 133 |
4 files changed, 148 insertions, 49 deletions
@@ -7,6 +7,8 @@ - Storing GPX - Export mountain pass from OSM and auto detect when pass is crossed - Auto detect climbs from GPX +- Compute estimated power +- Export data from Strava, Garmin ## Implementation @@ -20,11 +22,14 @@ - Django bike - https://github.com/paulmach/osm - https://github.com/tkrajina/gpxgo +- https://github.com/brendangregg/FlameGraph +- `go tool pprof -http=":8081"` ## TODO - CI / Tooling - Tests +- linter for calls to `make([]..., n)` that then use append ## Examples @@ -41,4 +46,5 @@ | 99.0km | 109.5km | | 128.2km | 141.8km | -- add min slope to prevent long false up +- add min slope to prevent long false up ? +- check score of 60km-76km vs 32km-76km @@ -1,14 +1,24 @@ module github.com/martinlehoux/biking_home -go 1.21.12 +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/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/gofrs/uuid v4.4.0+incompatible // indirect + github.com/kataras/i18n v0.0.8 // indirect + github.com/kr/text v0.2.0 // indirect github.com/mattn/go-runewidth v0.0.9 // indirect - golang.org/x/net v0.0.0-20210614182718-04defd469f4e // indirect - golang.org/x/text v0.3.6 // indirect + golang.org/x/exp v0.0.0-20240604190554-fc45aab8b7f8 // indirect + golang.org/x/net v0.26.0 // indirect + golang.org/x/text v0.16.0 // indirect + gopkg.in/ini.v1 v1.67.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect ) @@ -1,5 +1,25 @@ -github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8= +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/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= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +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/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= @@ -7,18 +27,28 @@ github.com/olekukonko/tablewriter v0.0.5/go.mod h1:hPp6KlRPjbx+hW8ykQs1w3UBbZlj6 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= -github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= +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/net v0.0.0-20210614182718-04defd469f4e h1:XpT3nA5TvE525Ne3hInMh6+GETgn27Zfm9dxsThnX2Q= +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/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/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= -golang.org/x/text v0.3.6 h1:aRYxNxv6iGQlyVaZmk6ZgYEDa+Jg18DxebPSrd6bg1M= golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.16.0 h1:a94ExnEXNtEwYLGJSIUxnWoxoRz/ZcCsV63ROupILh4= +golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c h1:dUUwHk2QECo/6vqA44rthZ8ie2QXMNeKRTHCNY2nXvo= +gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 h1:qIbj1fsPNlZgppZ+VLlY7N33q108Sa+fhmuc+sWQYwY= +gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA= +gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= @@ -1,24 +1,44 @@ 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" ) -func Score(points []gpx.GPXPoint) float64 { - distance := Distance(points) +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 - slope := (points[len(points)-1].Elevation.Value() - points[0].Elevation.Value()) / distance * 100.0 + dElevation := points[end].Elevation.Value() - points[start].Elevation.Value() - return math.Abs(slope) * slope * distance / 1000.0 + return math.Abs(dElevation) * dElevation / distance * 100.0 * 100.0 / 1000.0 } func Category(score float64) string { @@ -43,23 +63,45 @@ type Climb struct { end int } -func (c Climb) Score(points []gpx.GPXPoint) float64 { - return Score(points[c.start:c.end]) +type Point struct { + gpx.GPXPoint + distance float64 } -func BestUpHill(points []gpx.GPXPoint, start int, end int) Climb { - bestScore := Score(points[start:end]) +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]) + score := Score(points, i, end) if score > bestScore { bestStart = i bestScore = score } } bestEnd := end - for i := bestStart; i < end; i++ { - score := Score(points[bestStart:i]) + for i := end; i > bestStart; i-- { + score := Score(points, bestStart, i) if score > bestScore { bestEnd = i bestScore = score @@ -68,19 +110,20 @@ func BestUpHill(points []gpx.GPXPoint, start int, end int) Climb { return Climb{bestStart, bestEnd} } -func BestDownHill(points []gpx.GPXPoint, start int, end int) Climb { - bestScore := Score(points[start:end]) +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]) + score := Score(points, i, end) if score < bestScore { bestStart = i bestScore = score } } bestEnd := end - for i := bestStart; i < end; i++ { - score := Score(points[bestStart:i]) + for i := end; i > bestStart; i-- { + score := Score(points, bestStart, i) if score < bestScore { bestEnd = i bestScore = score @@ -89,26 +132,15 @@ func BestDownHill(points []gpx.GPXPoint, start int, end int) Climb { return Climb{bestStart, bestEnd} } -func Distance(points []gpx.GPXPoint) float64 { - distance := 0.0 - for i, point := range points { - if i > 0 { - distance += point.Distance2D(&points[i-1]) - } - } - return distance -} - -func FindAllClimbs(points []gpx.GPXPoint, start int, end int) []Climb { - from := Distance(points[:start]) - to := from + Distance(points[start:end]) - fmt.Printf("Searching climbs between %.1fkm and %.1fkm (%d-%d)\n", from/1000, to/1000, start, end) +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) - climbs := []Climb{} + Assert(climb.start < climb.end, "empty climb") - if climb.Score(points) < 35 { - if descent.Score(points) < -35 { + 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)...) } @@ -118,6 +150,7 @@ func FindAllClimbs(points []gpx.GPXPoint, start int, end int) []Climb { } } 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)...) } @@ -129,18 +162,38 @@ func FindAllClimbs(points []gpx.GPXPoint, start int, end int) []Climb { return climbs } +var cpuprofile = flag.String("cpuprofile", "", "write cpu profile to file") + func main() { - example, err := gpx.ParseFile("examples/2022-07-21.Pogacar.gpx") - if err != nil { - panic(err) + flag.Parse() + if *cpuprofile != "" { + f, err := os.Create(*cpuprofile) + kcore.Expect(err, "failed to create CPU profile") + pprof.StartCPUProfile(f) + defer pprof.StopCPUProfile() } - segment := example.Tracks[0].Segments[0] - climbs := FindAllClimbs(segment.Points, 0, len(segment.Points)-1) + 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 }) table := tablewriter.NewWriter(os.Stdout) - table.SetHeader([]string{"Score", "Distance", "Category", "Slope", "From", "To"}) + table.SetHeader([]string{"Score", "Distance", "Category", "Slope", "From", "To", "Point span"}) for _, climb := range climbs { - table.Append([]string{fmt.Sprintf("%d", int(climb.Score(segment.Points))), fmt.Sprintf("%.1fkm", Distance(segment.Points[climb.start:climb.end])/1000), Category(climb.Score(segment.Points)), fmt.Sprintf("%.1f%%", (segment.Points[climb.end].Elevation.Value()-segment.Points[climb.start].Elevation.Value())/Distance(segment.Points[climb.start:climb.end])*100), fmt.Sprintf("%.1fkm", Distance(segment.Points[:climb.start])/1000), fmt.Sprintf("%.1fkm", Distance(segment.Points[:climb.end])/1000)}) + table.Append([]string{fmt.Sprintf("%d", int(Score(ride.points, climb.start, climb.end))), fmt.Sprintf("%.1fkm", (ride.points[climb.end].distance-ride.points[climb.start].distance)/1000), Category(Score(ride.points, climb.start, climb.end)), fmt.Sprintf("%.1f%%", (ride.points[climb.end].Elevation.Value()-ride.points[climb.start].Elevation.Value())/(ride.points[climb.end].distance-ride.points[climb.start].distance)*100), 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.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) } |