diff options
| author | Martin Kagamino Lehoux <martin@lehoux.net> | 2025-04-21 11:51:03 +0200 |
|---|---|---|
| committer | Martin Kagamino Lehoux <martin@lehoux.net> | 2025-04-21 11:51:03 +0200 |
| commit | c47470b51075fbac1adea281f3324c1d22b6ff5f (patch) | |
| tree | 33df3b64d5153ac96f3d7f9086465cf81b7c8fad | |
| parent | c05cd9cb6305f15e3de985e5040142e0156a1286 (diff) | |
Cleanup parsing
| -rw-r--r-- | README.md | 2 | ||||
| -rw-r--r-- | main.go | 21 | ||||
| -rw-r--r-- | ride/.snapshots/TestRideClimbLaCride | 7 | ||||
| -rw-r--r-- | ride/climb.go | 5 | ||||
| -rw-r--r-- | ride/climb_test.go | 18 | ||||
| -rw-r--r-- | ride/index_test.go | 14 | ||||
| -rw-r--r-- | ride/ride.go | 98 |
7 files changed, 138 insertions, 27 deletions
@@ -3,6 +3,8 @@ ## Features - [ ] Detect similar climbs + PRs +- On this climb (examples/activity_18679866717.gpx), the climb algo should detect several possible climbs and let you choose +- Once you have chosen, new activities should have remembered the choice (looking into the index should be enough) - Handling my historical data - Blog with pictures and markdown - Storing GPX @@ -2,13 +2,16 @@ package main import ( "flag" + "log/slog" "os" "runtime/pprof" + "github.com/martinlehoux/biking_home/ride" "github.com/martinlehoux/kagamigo/kcore" ) var cpuprofile = flag.String("cpuprofile", "", "write cpu profile to file") +var parser = ride.GPXRideParser{} func main() { flag.Parse() @@ -18,4 +21,22 @@ func main() { pprof.StartCPUProfile(f) defer pprof.StopCPUProfile() } + ride19Jan, _ := ride.ParseFile(parser, "examples/activity_18043356988.gpx") + ride30Mar, _ := ride.ParseFile(parser, "examples/activity_18679866717.gpx") + ride20Apr, _ := ride.ParseFile(parser, "examples/activity_18880605641.gpx") + index := ride.NewFlatRideClimbsIndex(30) + index.Insert(ride19Jan) + index.Insert(ride30Mar) + for _, climb := range ride20Apr.AllClimbs() { + slog.Info("Climb", "climb", climb, "duration", climb.Duration(), "speed", climb.Speed()*3.6) + if len(index.Similar(ride20Apr, climb)) > 0 { + for _, similar := range index.Similar(ride20Apr, climb) { + slog.Info("Similar", "climb", similar, "duration", similar.Duration(), "speed", similar.Speed()*3.6) + } + } + } + for _, climb := range ride30Mar.AllClimbs() { + slog.Info("Climb", "climb", climb, "duration", climb.Duration(), "speed", climb.Speed()*3.6) + } + ride.PlotScore(&ride30Mar, 61.2, 66.7, "Ride 30 Mar.png") } diff --git a/ride/.snapshots/TestRideClimbLaCride b/ride/.snapshots/TestRideClimbLaCride index 386c553..456818e 100644 --- a/ride/.snapshots/TestRideClimbLaCride +++ b/ride/.snapshots/TestRideClimbLaCride @@ -1,4 +1,5 @@ -([]string) (len=2) { - (string) (len=69) "Found similar climbs to 17.1km-18.8km: 1.7km at 4.8% (38 pts - Cat 4)", - (string) (len=47) " 17.2km-18.8km: 1.6km at 4.9% (38 pts - Cat 4)" +([]string) (len=3) { + (string) (len=69) "Climb 17.1km-18.8km: 1.7km at 4.8% (38 pts - Cat 4) (5m50s, 17.0km/h)", + (string) (len=65) " 17.2km-18.8km: 1.6km at 4.9% (38 pts - Cat 4) (7m42s, 12.8km/h)", + (string) (len=70) "Climb 35.1km-40.1km: 5.1km at 4.3% (94 pts - Cat 3) (18m48s, 16.2km/h)" } diff --git a/ride/climb.go b/ride/climb.go index 069a7fb..df8798d 100644 --- a/ride/climb.go +++ b/ride/climb.go @@ -21,6 +21,11 @@ type Climb struct { func (climb Climb) Duration() time.Duration { return climb.points[len(climb.points)-1].Timestamp.Sub(climb.points[0].Timestamp) } + +func (climb Climb) Speed() float64 { + return (climb.End().DistanceM - climb.Start().DistanceM) / climb.Duration().Seconds() +} + func (climb Climb) Start() Point { return climb.points[0] } diff --git a/ride/climb_test.go b/ride/climb_test.go index 75aeb0e..ad00d65 100644 --- a/ride/climb_test.go +++ b/ride/climb_test.go @@ -9,7 +9,6 @@ import ( "github.com/martinlehoux/biking_home/ride" "github.com/martinlehoux/kagamigo/kcore" "github.com/stretchr/testify/assert" - "github.com/tkrajina/gpxgo/gpx" ) type RideBuilderSection struct { @@ -60,6 +59,8 @@ func (b RideBuilder) Build() ride.Ride { return ride.FromPoints(points) } +var parser = ride.GPXRideParser{} + func TestClimbThenFalseFlat(t *testing.T) { r := RideBuilder{precision: 100}.WithSection("2km at 7%").WithSection("10km at 1%").Build() climbs := r.AllClimbs() @@ -77,9 +78,8 @@ func TestSmallClimbWithDescentInsideFalseFlat(t *testing.T) { } func TestPogacar20220721(t *testing.T) { - gpxContent, err := gpx.ParseFile("../examples/2022-07-21.Pogacar.gpx") + r, err := ride.ParseFile(parser, "../examples/2022-07-21.Pogacar.gpx") assert.NoError(t, err) - r := ride.FromGPX(gpxContent) climbs := r.AllClimbs() assert.Len(t, climbs, 6) @@ -92,9 +92,8 @@ func TestPogacar20220721(t *testing.T) { } func TestBouclesVerdon2024(t *testing.T) { - gpxContent, err := gpx.ParseFile("../examples/2024-06-29.BouclesVerdon.gpx") + r, err := ride.ParseFile(parser, "../examples/2024-06-29.BouclesVerdon.gpx") assert.NoError(t, err) - r := ride.FromGPX(gpxContent) climbs := r.AllClimbs() assert.Len(t, climbs, 6) @@ -107,9 +106,8 @@ func TestBouclesVerdon2024(t *testing.T) { } func TestMimetArbois20241229(t *testing.T) { - gpxContent, err := gpx.ParseFile("../examples/2024-12-29.MimetArbois.gpx") + r, err := ride.ParseFile(parser, "../examples/2024-12-29.MimetArbois.gpx") assert.NoError(t, err) - r := ride.FromGPX(gpxContent) climbs := r.AllClimbs() assert.Len(t, climbs, 2) @@ -118,9 +116,8 @@ func TestMimetArbois20241229(t *testing.T) { } func TestMimetArbois20250104(t *testing.T) { - gpxContent, err := gpx.ParseFile("../examples/2025-01-04.MimetArbois.gpx") + r, err := ride.ParseFile(parser, "../examples/2025-01-04.MimetArbois.gpx") assert.NoError(t, err) - r := ride.FromGPX(gpxContent) climbs := r.AllClimbs() assert.Len(t, climbs, 2) @@ -130,9 +127,8 @@ func TestMimetArbois20250104(t *testing.T) { // https://www.strava.com/activities/9282265844 func TestAlpesVerdonTour20230617(t *testing.T) { - gpxContent, err := gpx.ParseFile("../examples/2023-06-17.AlpesVerdonTour.gpx") + r, err := ride.ParseFile(parser, "../examples/2023-06-17.AlpesVerdonTour.gpx") assert.NoError(t, err) - r := ride.FromGPX(gpxContent) climbs := r.AllClimbs() assert.Len(t, climbs, 6) diff --git a/ride/index_test.go b/ride/index_test.go index e95f030..7b3dc34 100644 --- a/ride/index_test.go +++ b/ride/index_test.go @@ -7,28 +7,24 @@ import ( "github.com/bradleyjkemp/cupaloy" "github.com/martinlehoux/biking_home/ride" "github.com/stretchr/testify/assert" - "github.com/tkrajina/gpxgo/gpx" ) func TestRideClimbLaCride(t *testing.T) { - gpxContent, err := gpx.ParseFile("../examples/activity_18043356988.gpx") + ride19Jan, err := ride.ParseFile(parser, "../examples/activity_18043356988.gpx") assert.NoError(t, err) - ride19Jan := ride.FromGPX(gpxContent) - gpxContent, err = gpx.ParseFile("../examples/activity_18679866717.gpx") + ride30Mar, err := ride.ParseFile(parser, "../examples/activity_18679866717.gpx") assert.NoError(t, err) - ride30Mar := ride.FromGPX(gpxContent) - gpxContent, err = gpx.ParseFile("../examples/activity_18880605641.gpx") + ride20Apr, err := ride.ParseFile(parser, "../examples/activity_18880605641.gpx") assert.NoError(t, err) - ride20Apr := ride.FromGPX(gpxContent) index := ride.NewFlatRideClimbsIndex(30) index.Insert(ride19Jan) index.Insert(ride30Mar) logs := []string{} for _, climb := range ride20Apr.AllClimbs() { + logs = append(logs, fmt.Sprintf("Climb %s (%s, %.1fkm/h)", climb, climb.Duration(), climb.Speed()*3.6)) if len(index.Similar(ride20Apr, climb)) > 0 { - logs = append(logs, fmt.Sprintf("Found similar climbs to %s", climb)) for _, similar := range index.Similar(ride20Apr, climb) { - logs = append(logs, fmt.Sprintf(" %s", similar)) + logs = append(logs, fmt.Sprintf(" %s (%s, %.1fkm/h)", similar, similar.Duration(), similar.Speed()*3.6)) } } } diff --git a/ride/ride.go b/ride/ride.go index 094a8f6..f5a92a5 100644 --- a/ride/ride.go +++ b/ride/ride.go @@ -1,6 +1,9 @@ package ride import ( + "errors" + "io" + "os" "time" "github.com/jftuga/geodist" @@ -8,6 +11,7 @@ import ( "github.com/tkrajina/gpxgo/gpx" "gonum.org/v1/plot" "gonum.org/v1/plot/plotter" + "gonum.org/v1/plot/plotutil" "gonum.org/v1/plot/vg" ) @@ -26,7 +30,26 @@ func (r *Ride) check() { kcore.Assert(len(r.points) > 0, "no points in ride") } -func FromGPX(content *gpx.GPX) Ride { +type RideParser interface { + Parse(reader io.Reader) (Ride, error) +} + +func ParseFile(parser RideParser, filename string) (Ride, error) { + f, err := os.Open(filename) + if err != nil { + return Ride{}, err + } + defer f.Close() + return parser.Parse(f) +} + +type GPXRideParser struct{} + +func (p GPXRideParser) Parse(reader io.Reader) (Ride, error) { + content, err := gpx.Parse(reader) + if err != nil { + return Ride{}, err + } segment := content.Tracks[0].Segments[0] points := make([]Point, len(segment.Points)) distance := 0.0 @@ -34,13 +57,17 @@ func FromGPX(content *gpx.GPX) Ride { 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") + if i != 0 && distance == 0 { + return Ride{}, errors.New("zero distance") + } + if p.Elevation.Null() { + return Ride{}, errors.New("points without elevation") + } points[i] = Point{DistanceM: distance, ElevationM: p.Elevation.Value(), Coord: geodist.Coord{Lat: p.Latitude, Lon: p.Longitude}, Timestamp: p.Timestamp} } ride := Ride{points} ride.check() - return ride + return ride, nil } func FromPoints(points []Point) Ride { @@ -99,3 +126,66 @@ func Plot(r *Ride, outputFile string) { err = p.Save(10*vg.Inch, 4*vg.Inch, outputFile) kcore.Expect(err, "failed to save plot") } + +func PlotScore(r *Ride, startKm, endKm float64, outputFile string) { + r.check() + + startIndex := 0 + for i, p := range r.points { + if p.DistanceM >= startKm*1000 { + startIndex = i + break + } + } + + pts1 := make(plotter.XYs, 0) + endIndex := 0 + for i := startIndex + 1; i < len(r.points); i++ { + if r.points[i].DistanceM > endKm*1000 { + endIndex = i + break + } + score1 := Score(r.points, startIndex, i) + pts1 = append(pts1, plotter.XY{ + X: r.points[i].DistanceM / 1000, // Convert distance to kilometers + Y: score1, + }) + } + pts2 := make(plotter.XYs, 0) + for i := startIndex; i < endIndex; i++ { + score2 := Score(r.points, i, endIndex) + pts2 = append(pts2, plotter.XY{ + X: r.points[i].DistanceM / 1000, // Convert distance to kilometers + Y: score2, + }) + } + + p := plot.New() + p.Title.Text = "Ride Score Profile" + p.X.Label.Text = "Distance (km)" + p.Y.Label.Text = "Score" + + line1, err := plotter.NewLine(pts1) + kcore.Expect(err, "failed to create line plot for score1") + line1.Color = plotutil.Color(0) // First line color + maxIndex := 0 + maxValue := pts1[0].Y + for i, pt := range pts1 { + if pt.Y > maxValue { + maxValue = pt.Y + maxIndex = i + } + } + println("Max value of line 1 is at distance:", pts1[maxIndex].X, "km") + + line2, err := plotter.NewLine(pts2) + kcore.Expect(err, "failed to create line plot for score2") + line2.Color = plotutil.Color(1) // Second line color + + p.Add(line1, line2) + p.Legend.Add("Score (start to i)", line1) + p.Legend.Add("Score (i to end)", line2) + + err = p.Save(10*vg.Inch, 4*vg.Inch, outputFile) + kcore.Expect(err, "failed to save plot") +} |