From 825cf388b55a6c8441e02149749bb6e2968477c0 Mon Sep 17 00:00:00 2001 From: Martin Kagamino Lehoux Date: Thu, 27 Aug 2026 11:21:17 +0200 Subject: Extract climb detection into a dedicated climb package Move climb detection, slope, and Cotacol scoring out of ride into a new climb package that operates on a geometry-only Route (distances, elevations, coords). Ride embeds climb.Route and keeps its timing and telemetry columns. - climb.Detect returns []DetectedClimb (Climb + ride-absolute Start). - Climb owns its sliced geometry; indices are local, with Start reported at the detection boundary for absolute position. - Detected bounds use core.Interval; freeRegions claim suppression fixed. - ride no longer builds slices: ClimbFromDist/ClimbFromIndexes/climbFrom and ride.Climb/Interval/Range are removed; rideanalysis, mountain_pass MatchClimb, and web views are rewired to climb types. - New internal/routetest builds climb.Route from the ride DSL; ridetest reuses it and adds timestamps and telemetry. - Tests moved to climb (synthetic detection and difficulty) and kept in ride for GPX-example integration; ride.comparison detection coverage retained. - mountain_pass.nearestCrossing now reuses geo.Nearest. --- climb/difficulty_test.go | 72 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 72 insertions(+) create mode 100644 climb/difficulty_test.go (limited to 'climb/difficulty_test.go') diff --git a/climb/difficulty_test.go b/climb/difficulty_test.go new file mode 100644 index 0000000..92c0a74 --- /dev/null +++ b/climb/difficulty_test.go @@ -0,0 +1,72 @@ +package climb_test + +import ( + "math" + "testing" + + "github.com/martinlehoux/biking_home/climb" + "github.com/martinlehoux/biking_home/internal/routetest" + "github.com/stretchr/testify/assert" +) + +func TestCotacolConstantClimb(t *testing.T) { + route := routetest.MustRoute("2km at 7%") + assert.InDelta(t, 98.0, climb.Cotacol(route.Distances, route.Elevations), 1e-9) +} + +func TestCotacolIgnoresDescent(t *testing.T) { + route := routetest.MustRoute("2km at 7% | 2km at -7%") + assert.InDelta(t, 98.0, climb.Cotacol(route.Distances, route.Elevations), 1e-9) +} + +func TestCotacolFlatIsZero(t *testing.T) { + route := routetest.MustRoute("5km at 0%") + assert.Zero(t, climb.Cotacol(route.Distances, route.Elevations)) +} + +func TestCotacolSteepShortCountsMore(t *testing.T) { + steep := routetest.MustRoute("0.5km at 14%") + long := routetest.MustRoute("1km at 7%") + assert.InDelta(t, 98.0, climb.Cotacol(steep.Distances, steep.Elevations), 1e-9) + assert.InDelta(t, 49.0, climb.Cotacol(long.Distances, long.Elevations), 1e-9) +} + +func TestCotacolPrecisionInvariant(t *testing.T) { + coarse := routetest.MustRoute("3km at 5%") + fine := routetest.MustRoute("3km at 5%", routetest.Sampling(50)) + assert.InDelta(t, climb.Cotacol(coarse.Distances, coarse.Elevations), climb.Cotacol(fine.Distances, fine.Elevations), 1e-9) +} + +func TestCotacolClimbNotAtStart(t *testing.T) { + route := routetest.MustRoute("1km at 0% | 2km at 7%") + detected := climb.Detect(route, nil, climb.ClimbSlopeMinimum) + assert.Len(t, detected, 1) + assert.InDelta(t, 98.0, detected[0].Climb.DifficultyScore(), 1e-9) +} + +func TestCategoryOfBoundaries(t *testing.T) { + cases := []struct { + score float64 + want climb.Category + }{ + {-5, climb.CategoryNo}, + {30, climb.CategoryNo}, + {35, climb.CategoryFour}, // 35 is the first floor, not below it + {79, climb.CategoryFour}, + {80, climb.CategoryThree}, + {179, climb.CategoryThree}, + {180, climb.CategoryTwo}, + {249, climb.CategoryTwo}, + {250, climb.CategoryOne}, + {599, climb.CategoryOne}, + {600, climb.CategoryHC}, + {700, climb.CategoryHC}, + {math.NaN(), climb.CategoryHC}, // reaches no floor, like the original switch default + } + + for _, c := range cases { + assert.Equal(t, c.want, climb.CategoryOf(c.score), "score %v", c.score) + } + assert.Equal(t, "Cat 1", climb.CategoryOne.String()) + assert.Equal(t, "NO", climb.CategoryNo.String()) +} -- cgit v1.3