diff options
| author | Martin Kagamino Lehoux <martin@lehoux.net> | 2024-07-28 17:50:29 +0200 |
|---|---|---|
| committer | Martin Kagamino Lehoux <martin@lehoux.net> | 2024-07-28 17:50:29 +0200 |
| commit | 203b9bdf17bd4742b5aab2c35df47b59af0652f1 (patch) | |
| tree | b6bc8ddee21bcaa022ef68b3769f61100117b190 /ride | |
| parent | 171366f337c3422331b84b6c7416e1b1d6bd36a0 (diff) | |
test: Add some test to fix behavior
Diffstat (limited to 'ride')
| -rw-r--r-- | ride/climb.go | 113 | ||||
| -rw-r--r-- | ride/climb_test.go | 82 | ||||
| -rw-r--r-- | ride/ride.go | 46 |
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)) +} |