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. --- README.md | 2 +- climb/climb.go | 87 +++++++++ climb/cotacol.go | 48 +++++ climb/detection.go | 175 ++++++++++++++++++ climb/detection_test.go | 84 +++++++++ climb/difficulty_test.go | 72 ++++++++ climb/route.go | 9 + core/core.go | 12 ++ internal/ridetest/ride.go | 298 ++---------------------------- internal/ridetest/ride_test.go | 48 ++--- internal/routetest/routetest.go | 331 ++++++++++++++++++++++++++++++++++ mountain_pass/detection.go | 33 ++-- mountain_pass/detection_test.go | 24 +-- official_climb/official_climb_test.go | 28 +-- ride/climb.go | 329 --------------------------------- ride/climb_test.go | 135 -------------- ride/detection_example_test.go | 87 +++++++++ ride/difficulty_example_test.go | 25 +++ ride/difficulty_test.go | 87 --------- ride/ecs_test.go | 43 ----- ride/moving_time.go | 2 +- ride/parser_test.go | 2 +- ride/ride.go | 109 ++--------- ride/store.go | 7 +- ride/store_test.go | 11 +- rideanalysis/analysis.go | 49 +++-- web/ride-filters.go | 3 +- web/ride-filters_test.go | 3 +- web/rides.go | 7 +- web/server_test.go | 13 +- web/views.go | 24 +-- 31 files changed, 1100 insertions(+), 1087 deletions(-) create mode 100644 climb/climb.go create mode 100644 climb/cotacol.go create mode 100644 climb/detection.go create mode 100644 climb/detection_test.go create mode 100644 climb/difficulty_test.go create mode 100644 climb/route.go create mode 100644 core/core.go create mode 100644 internal/routetest/routetest.go delete mode 100644 ride/climb.go delete mode 100644 ride/climb_test.go create mode 100644 ride/detection_example_test.go create mode 100644 ride/difficulty_example_test.go delete mode 100644 ride/difficulty_test.go delete mode 100644 ride/ecs_test.go diff --git a/README.md b/README.md index 4d9a1f8..1ae3a8b 100644 --- a/README.md +++ b/README.md @@ -154,7 +154,7 @@ Add `github.com/grafana/pyroscope-go` and call `pyroscope.Start` with `ServerAdd ## TODO -- use interval +- pyroscope - screaming archi + hexagon - Compute estimated power, speed - For a ride, scatter, one point per 100m segment. Slope vs speed diff --git a/climb/climb.go b/climb/climb.go new file mode 100644 index 0000000..2d764dd --- /dev/null +++ b/climb/climb.go @@ -0,0 +1,87 @@ +package climb + +import ( + "fmt" + "math" +) + +const ( + ClimbDistanceMinimum = 500 + ClimbSlopeMinimum = 0.035 + detectionMinimumScore = 35 +) + +type Category int + +const ( + CategoryNo Category = iota + CategoryFour + CategoryThree + CategoryTwo + CategoryOne + CategoryHC +) + +type categoryInfo struct { + label string + minScore float64 +} + +var categories = map[Category]categoryInfo{ + CategoryNo: {label: "NO", minScore: math.Inf(-1)}, + CategoryFour: {label: "Cat 4", minScore: 35}, + CategoryThree: {label: "Cat 3", minScore: 80}, + CategoryTwo: {label: "Cat 2", minScore: 180}, + CategoryOne: {label: "Cat 1", minScore: 250}, + CategoryHC: {label: "HC", minScore: 600}, +} + +func (c Category) String() string { + return categories[c].label +} + +func CategoryOf(cotacol float64) Category { + best := CategoryNo + bestMin := math.Inf(-1) + found := false + for category, info := range categories { + if cotacol >= info.minScore && (!found || info.minScore > bestMin) { + best = category + bestMin = info.minScore + found = true + } + } + if !found { + // NaN reaches no floor; preserve the original switch default (HC). + return CategoryHC + } + return best +} + +type Climb struct { + Route +} + +func (climb Climb) String() string { + cotacol := climb.DifficultyScore() + return fmt.Sprintf("%.1fkm at %.1f%% (%d pts - %s)", climb.Distances[len(climb.Distances)-1]/1000, Slope(climb.Distances, climb.Elevations)*100, int(cotacol), CategoryOf(cotacol)) +} + +func (climb Climb) DifficultyScore() float64 { + return Cotacol(climb.Distances, climb.Elevations) +} + +func (climb Climb) Top() (int, float64) { + top := 0 + for i := 1; i < len(climb.Elevations); i++ { + if climb.Elevations[i] > climb.Elevations[top] { + top = i + } + } + return top, climb.Elevations[top] +} + +func Slope(distances, elevations []float64) float64 { + end := len(distances) - 1 + return (elevations[end] - elevations[0]) / (distances[end] - distances[0]) +} diff --git a/climb/cotacol.go b/climb/cotacol.go new file mode 100644 index 0000000..85b096b --- /dev/null +++ b/climb/cotacol.go @@ -0,0 +1,48 @@ +package climb + +import "math" + +const ( + CotacolAlgorithmVersion = "v1" + cotacolSegmentLengthM = 100 +) + +func Cotacol(distances, elevations []float64) float64 { + startIndex, endIndex := 0, len(distances)-1 + if endIndex-startIndex < 1 { + return 0 + } + startDistance := distances[startIndex] + lastDistance := distances[endIndex] + if lastDistance <= startDistance { + return 0 + } + cotacol := 0.0 + i := startIndex + for start := startDistance; start < lastDistance; start += cotacolSegmentLengthM { + end := math.Min(start+cotacolSegmentLengthM, lastDistance) + for i < endIndex && distances[i+1] <= start { + i++ + } + startElevation := interpolateElevation(distances, elevations, i, start) + for i < endIndex && distances[i+1] < end { + i++ + } + endElevation := interpolateElevation(distances, elevations, i, end) + slope := (endElevation - startElevation) / (end - start) + if slope > 0 { + cotacol += (end - start) / 1000 * (slope * 100) * (slope * 100) + } + } + return cotacol +} + +func interpolateElevation(distances, elevations []float64, i int, distance float64) float64 { + if i+1 >= len(distances) { + return elevations[i] + } + startDistance := distances[i] + endDistance := distances[i+1] + t := (distance - startDistance) / (endDistance - startDistance) + return elevations[i] + t*(elevations[i+1]-elevations[i]) +} diff --git a/climb/detection.go b/climb/detection.go new file mode 100644 index 0000000..1efe1c5 --- /dev/null +++ b/climb/detection.go @@ -0,0 +1,175 @@ +package climb + +import ( + "log/slog" + "math" + "slices" + + "github.com/martinlehoux/biking_home/core" + "github.com/martinlehoux/kagamigo/kcore" +) + +type DetectedClimb struct { + Climb Climb + Start int +} + +// returns the detected climbs but never a climb that overlaps a claimed range. +func Detect(route Route, claimed []core.Interval, minSlope float64) []DetectedClimb { + distances, elevations, coords := route.Distances, route.Elevations, route.Coords + bounds := make([]core.Interval, 0) + for _, region := range freeRegions(0, len(distances)-1, claimed) { + bounds = append(bounds, climbsBetween(distances, elevations, core.NewInterval(region.Start, region.End), minSlope)...) + } + slices.SortFunc(bounds, func(a, b core.Interval) int { return a.Start - b.Start }) + climbs := make([]DetectedClimb, 0, len(bounds)) + for _, b := range bounds { + climbs = append(climbs, DetectedClimb{ + Climb: Climb{Route: Route{ + Distances: distances[b.Start : b.End+1], + Elevations: elevations[b.Start : b.End+1], + Coords: coords[b.Start : b.End+1], + }}, + Start: b.Start, + }) + } + return climbs +} + +// freeRegions returns the maximal sub-ranges of [rStart, rEnd] that are not +// covered by any claimed range, after discarding claims that fall outside. +func freeRegions(rStart, rEnd int, claimed []core.Interval) []core.Interval { + valid := make([]core.Interval, 0, len(claimed)) + for _, claim := range claimed { + // Discard claims outside the route or with a degenerate range. + if claim.Start < 0 || claim.End >= rEnd+1 || claim.Start >= claim.End { + continue + } + valid = append(valid, claim) + } + slices.SortFunc(valid, func(a, b core.Interval) int { return a.Start - b.Start }) + regions := make([]core.Interval, 0, len(valid)+1) + current := rStart + for _, claim := range valid { + if claim.End < current { + continue + } + if claim.Start > current { + regions = append(regions, core.NewInterval(current, claim.Start-1)) + } + if claim.End+1 > current { + current = claim.End + 1 + } + } + if current <= rEnd { + regions = append(regions, core.NewInterval(current, rEnd)) + } + return regions +} + +// climbsBetween returns the detected climb bounds inside bounds from the +// distance and elevation components. +func climbsBetween(distances, elevations []float64, bounds core.Interval, minSlope float64) []core.Interval { + start, end := bounds.Start, bounds.End + climbs := []core.Interval{} + if distances[end]-distances[start] < ClimbDistanceMinimum { + return climbs + } + slog.Debug("Searching climbs between", slog.Int("start", int(distances[start])), slog.Int("end", int(distances[end]))) + highest := start + for i := start; i <= end; i++ { + if elevations[i] > elevations[highest] { + highest = i + } + } + // TODO: Use descent to reduce recursion + if distances[highest]-distances[start] < ClimbDistanceMinimum { + return climbsBetween(distances, elevations, core.NewInterval(start+1, end), minSlope) + } + climb, found := bestClimbBetween(distances[start:highest+1], elevations[start:highest+1], minSlope) + if !found { + // No qualifying climb up to the top; only the part past it can hold climbs. + return climbsBetween(distances, elevations, core.NewInterval(highest, end), minSlope) + } + // bestClimbBetween returns an interval relative to its sliced input; + // re-anchor it into this slice's index space. + climbStart := start + climb.Start + climbEnd := start + climb.End + if climbDetectionScore(distances[climbStart:climbEnd+1], elevations[climbStart:climbEnd+1]) >= detectionMinimumScore && distances[climbEnd]-distances[climbStart] >= ClimbDistanceMinimum { + slog.Debug("Found climb between", slog.Int("start", int(distances[climbStart])), slog.Int("end", int(distances[climbEnd]))) + climbs = append(climbs, core.NewInterval(climbStart, climbEnd)) + } + climbs = append(climbs, climbsBetween(distances, elevations, core.NewInterval(start, climbStart), minSlope)...) + climbs = append(climbs, climbsBetween(distances, elevations, core.NewInterval(climbEnd, end), minSlope)...) + + return climbs +} + +func climbDetectionScore(distances, elevations []float64) float64 { + kcore.Assert(len(distances) > 0, "no points for climb detection") + end := len(distances) - 1 + distance := distances[end] - distances[0] + if distance == 0 { + return 0 + } + dElevation := elevations[end] - elevations[0] + return math.Abs(dElevation) * dElevation / distance * 100.0 * 100.0 / 1000.0 +} + +// bestClimbBetween returns the highest-scoring sub-interval of the segment +// given by [0, len-1]; when that climb averages below minSlope, it instead +// returns the best qualifying sub-segment (found=false when none exists). The +// returned interval is relative to the start of the given (already sliced) +// slices; callers add back their offset to recover absolute indices. +func bestClimbBetween(distances, elevations []float64, minSlope float64) (core.Interval, bool) { + end := len(distances) - 1 + kcore.Assert(end > 0, "empty points") + + bestDetectionScore := climbDetectionScore(distances, elevations) + best := core.NewInterval(0, end) + for i := 0; i < end; i++ { + detectionScore := climbDetectionScore(distances[i:end], elevations[i:end]) + if detectionScore > bestDetectionScore { + best.Start = i + bestDetectionScore = detectionScore + } + } + for i := end; i > best.Start; i-- { + detectionScore := climbDetectionScore(distances[best.Start:i+1], elevations[best.Start:i+1]) + if detectionScore > bestDetectionScore { + best.End = i + bestDetectionScore = detectionScore + } + } + for i := best.Start; i < best.End; i++ { + detectionScore := climbDetectionScore(distances[i:best.End], elevations[i:best.End]) + if detectionScore > bestDetectionScore { + best.Start = i + bestDetectionScore = detectionScore + } + } + kcore.Assert(best.Start < best.End, "empty climb") + if minSlope <= 0 || Slope(distances[best.Start:best.End+1], elevations[best.Start:best.End+1]) >= minSlope { + return best, true + } + // Classic best too gentle: re-segment into the best qualifying sub-segment. + bestDetectionScore = math.Inf(-1) + best = core.NewInterval(-1, -1) + for i := 0; i < end; i++ { + for j := i + 1; j <= end; j++ { + if Slope(distances[i:j+1], elevations[i:j+1]) < minSlope { + continue + } + detectionScore := climbDetectionScore(distances[i:j+1], elevations[i:j+1]) + if detectionScore > bestDetectionScore { + bestDetectionScore = detectionScore + best.Start = i + best.End = j + } + } + } + if best.Start < 0 { + return core.Interval{}, false + } + return best, true +} diff --git a/climb/detection_test.go b/climb/detection_test.go new file mode 100644 index 0000000..3c6a33b --- /dev/null +++ b/climb/detection_test.go @@ -0,0 +1,84 @@ +package climb_test + +import ( + "fmt" + "testing" + + "github.com/martinlehoux/biking_home/climb" + "github.com/martinlehoux/biking_home/core" + "github.com/martinlehoux/biking_home/internal/routetest" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// describe renders a detected climb as the ride-style +// "start-end: length at slope (pts - cat)" string, so positions are asserted. +func describe(route climb.Route, detected climb.DetectedClimb) string { + seg := detected.Climb + startKm := route.Distances[detected.Start] / 1000 + endKm := route.Distances[detected.Start+len(seg.Distances)-1] / 1000 + lengthKm := (seg.Distances[len(seg.Distances)-1] - seg.Distances[0]) / 1000 + slopePercent := climb.Slope(seg.Distances, seg.Elevations) * 100 + pts := int(seg.DifficultyScore()) + return fmt.Sprintf("%.1fkm-%.1fkm: %.1fkm at %.1f%% (%d pts - %s)", startKm, endKm, lengthKm, slopePercent, pts, climb.CategoryOf(seg.DifficultyScore())) +} + +func TestClimbThenFalseFlat(t *testing.T) { + route := routetest.MustRoute("2km at 7% | 10km at 1%") + detected := climb.Detect(route, nil, climb.ClimbSlopeMinimum) + + assert.Len(t, detected, 1) + assert.Equal(t, "0.0km-2.0km: 2.0km at 7.0% (98 pts - Cat 3)", describe(route, detected[0])) +} + +func TestSmallClimbWithDescentInsideFalseFlat(t *testing.T) { + route := routetest.MustRoute("10km at 1% | 2km at 7% | 1km at -7% | 10km at 1%") + detected := climb.Detect(route, nil, climb.ClimbSlopeMinimum) + + assert.Len(t, detected, 1) + assert.Equal(t, "10.0km-12.0km: 2.0km at 7.0% (98 pts - Cat 3)", describe(route, detected[0])) +} + +func TestDetectClimbsSkipsMiddleClaim(t *testing.T) { + // A climb, a false flat giving a saddle, then a climb again. Claiming the + // saddle's descent base should leave both climbs detected separately. + route := routetest.MustRoute("2km at 7% | 1km at -7% | 2km at 7%") + detected := climb.Detect(route, []core.Interval{core.NewInterval(20, 20)}, climb.ClimbSlopeMinimum) + + // No free region falls under the min distance except the two climbs. + assert.Len(t, detected, 2) + assert.Equal(t, "0.0km-2.0km: 2.0km at 7.0% (98 pts - Cat 3)", describe(route, detected[0])) + assert.Equal(t, "3.0km-5.0km: 2.0km at 7.0% (98 pts - Cat 3)", describe(route, detected[1])) +} + +func TestDetectClimbsOverlappingClaimDropsClimb(t *testing.T) { + // Claiming the ascent region entirely swallows the climb. + route := routetest.MustRoute("2km at 7% | 20km at 0%") + detected := climb.Detect(route, []core.Interval{core.NewInterval(0, 40)}, climb.ClimbSlopeMinimum) + assert.Empty(t, detected) +} + +func TestDetectClimbsIgnoresOutOfRangeClaim(t *testing.T) { + route := routetest.MustRoute("2km at 7% | 20km at 0%") + detected := climb.Detect(route, []core.Interval{core.NewInterval(-5, -1)}, climb.ClimbSlopeMinimum) + require.Len(t, detected, 1) + assert.Equal(t, "0.0km-2.0km: 2.0km at 7.0% (98 pts - Cat 3)", describe(route, detected[0])) +} + +func TestDetectClimbsWithUnsortedClaimsDropsOverlappingClimb(t *testing.T) { + // Reproduces ride 6 Chemin des Lauriers bug: Detect must not depend on + // caller-provided order. Two official claims out of ID order (Bastides + // 2140-2627 before Lauriers 1215-1588) caused freeRegions to skip the + // earlier claim and leave the overlapping Cat 4 1352-1601 visible. + // Minimal reproduction: two climbs 0-2km and 3-5km separated by a descent. + route := routetest.MustRoute("2km at 7% | 1km at -7% | 2km at 7%") + // Both climbs claimed, but in reverse order (mimics ORDER BY id returning 30-50 before 0-20). + // Sorted input correctly yields 0 detected climbs. + sorted := []core.Interval{core.NewInterval(0, 20), core.NewInterval(30, 50)} + assert.Empty(t, climb.Detect(route, sorted, climb.ClimbSlopeMinimum)) + + unsorted := []core.Interval{core.NewInterval(30, 50), core.NewInterval(0, 20)} + detected := climb.Detect(route, unsorted, climb.ClimbSlopeMinimum) + // Before fix this fails: freeRegions skips {0,20} because 20 < 30 and returns 1 climb. + assert.Empty(t, detected, "unsorted claimed ranges must still suppress overlapping climbs — expected 0, got %v", detected) +} 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()) +} diff --git a/climb/route.go b/climb/route.go new file mode 100644 index 0000000..80a9a43 --- /dev/null +++ b/climb/route.go @@ -0,0 +1,9 @@ +package climb + +import "github.com/jftuga/geodist" + +type Route struct { + Distances []float64 + Elevations []float64 + Coords []geodist.Coord +} diff --git a/core/core.go b/core/core.go new file mode 100644 index 0000000..e2923c1 --- /dev/null +++ b/core/core.go @@ -0,0 +1,12 @@ +package core + +type Interval struct { + Start, End int +} + +func NewInterval(start, end int) Interval { + return Interval{ + Start: start, + End: end, + } +} diff --git a/internal/ridetest/ride.go b/internal/ridetest/ride.go index e74ce49..50a84cd 100644 --- a/internal/ridetest/ride.go +++ b/internal/ridetest/ride.go @@ -1,135 +1,36 @@ -// Package ridetest builds ride.Ride test fixtures from a compact string DSL. +// Package ridetest builds ride.Ride test fixtures from a compact string DSL. It +// reuses routetest to sample the route geometry and layers ride timestamps and +// telemetry on top. // -// Grammar: sections separated by "|", each "km at %" with -// optional per-section metrics "hr=150 cad=80 w=200": -// -// ridetest.MustRide("2km at 7% | 1km at -7%") -// -// Options adjust the generated columns: Sampling (step in meters, default -// 100), StartCoord (first point; later points progress northeast at the -// Heading rate, default 0.01° of latitude and longitude per kilometer), -// StartTime (default the zero time, one second between points) and -// StartElevation (default 0). +// Grammar: see routetest. package ridetest import ( - "fmt" - "strconv" - "strings" - "time" - - "github.com/jftuga/geodist" + "github.com/martinlehoux/biking_home/internal/routetest" "github.com/martinlehoux/biking_home/ride" ) -const defaultSamplingM = 100 - -// headingPerKm is the latitude and longitude offset applied to each point per -// kilometer of ride, giving StartCoord rides a fixed northeast direction. -const headingPerKm = 0.01 - -type section struct { - distanceM float64 - slope float64 - heartRate *float64 - cadence *float64 - power *float64 -} - -type point struct { - distanceM float64 - elevationM float64 - coord geodist.Coord - timestamp time.Time - section section -} - -type options struct { - samplingM float64 - startCoord *geodist.Coord - headingLat float64 - headingLon float64 - startTime time.Time - startElevM float64 -} - -// Option configures the ride generated by Ride. -type Option func(*options) - -// Sampling sets the distance between consecutive points in meters -// (default 100). -func Sampling(m float64) Option { - return func(o *options) { o.samplingM = m } -} - -// StartCoord sets the coordinate of the first point. Each following point -// progresses northeast at the rate set by Heading, default 0.01° of latitude -// and longitude per kilometer. -func StartCoord(lat, lon float64) Option { - return func(o *options) { - o.startCoord = &geodist.Coord{Lat: lat, Lon: lon} - if o.headingLat == 0 { - o.headingLat = headingPerKm - } - if o.headingLon == 0 { - o.headingLon = headingPerKm - } - } -} +// Option is re-exported from routetest so existing ridetest callers keep +// working unchanged. +type Option = routetest.Option -// Heading sets the latitude and longitude offset applied to each point per -// kilometer of ride, in degrees (default 0.01 northeast when StartCoord is -// set). Only meaningful together with StartCoord. -func Heading(latPerKm, lonPerKm float64) Option { - return func(o *options) { - o.headingLat = latPerKm - o.headingLon = lonPerKm - } -} - -// StartTime sets the timestamp of the first point; each following point is -// one second later (default: the zero time). -func StartTime(t time.Time) Option { - return func(o *options) { o.startTime = t } -} - -// StartElevation sets the elevation of the first point, in meters (default 0). -func StartElevation(m float64) Option { - return func(o *options) { o.startElevM = m } -} +var ( + Sampling = routetest.Sampling + StartCoord = routetest.StartCoord + Heading = routetest.Heading + StartTime = routetest.StartTime + StartElevation = routetest.StartElevation +) // Ride parses a ride description into a ride.Ride. // // Example: "2km at 7% | 1km at -7%". func Ride(dsl string, opts ...Option) (ride.Ride, error) { - o := options{samplingM: defaultSamplingM, headingLat: headingPerKm, headingLon: headingPerKm} - for _, opt := range opts { - opt(&o) - } - if o.samplingM <= 0 { - return ride.Ride{}, fmt.Errorf("ridetest: sampling must be positive, got %v", o.samplingM) - } - sections, err := parseSections(dsl) + built, err := routetest.Build(dsl, opts...) if err != nil { return ride.Ride{}, err } - if len(sections) == 0 { - return ride.Ride{}, fmt.Errorf("ridetest: empty ride description") - } - for _, metric := range []struct { - name string - get func(section) *float64 - }{ - {"hr", func(s section) *float64 { return s.heartRate }}, - {"cad", func(s section) *float64 { return s.cadence }}, - {"w", func(s section) *float64 { return s.power }}, - } { - present, complete := metricComplete(sections, metric.get) - if present && !complete { - return ride.Ride{}, fmt.Errorf("ridetest: %s= must be set on every section or none", metric.name) - } - } - return buildRide(sections, o), nil + return ride.FromColumnsWithMetrics(built.Distances, built.Elevations, built.Coords, built.Timestamps, built.HeartRates, built.Cadences, built.Powers), nil } // MustRide is like Ride but panics on a malformed description; use it inside @@ -141,168 +42,3 @@ func MustRide(dsl string, opts ...Option) ride.Ride { } return ride } - -func parseSections(dsl string) ([]section, error) { - rawSections := strings.Split(dsl, "|") - sections := make([]section, 0, len(rawSections)) - for _, raw := range rawSections { - raw = strings.TrimSpace(raw) - if raw == "" { - return nil, fmt.Errorf("ridetest: empty section in %q", dsl) - } - sec, err := parseSection(raw) - if err != nil { - return nil, err - } - sections = append(sections, sec) - } - return sections, nil -} - -// parseSection parses a single section like "2km at 7% hr=150 cad=80 w=200". -func parseSection(raw string) (section, error) { - fields := strings.Fields(raw) - if len(fields) < 3 { - return section{}, fmt.Errorf("ridetest: section %q must look like \"2km at 7%%\"", raw) - } - if fields[1] != "at" { - return section{}, fmt.Errorf("ridetest: section %q: expected \"at\" between distance and slope, got %q", raw, fields[1]) - } - distanceKm, err := parseUnit(fields[0], "km") - if err != nil { - return section{}, fmt.Errorf("ridetest: section %q: %w", raw, err) - } - slopePercent, err := parseUnit(fields[2], "%") - if err != nil { - return section{}, fmt.Errorf("ridetest: section %q: %w", raw, err) - } - if distanceKm <= 0 { - return section{}, fmt.Errorf("ridetest: section %q: distance must be positive", raw) - } - sec := section{ - distanceM: distanceKm * 1000, - slope: slopePercent / 100, - } - for _, token := range fields[3:] { - var value *float64 - switch { - case strings.HasPrefix(token, "hr="): - value, err = parseMetric(token, "hr") - sec.heartRate = value - case strings.HasPrefix(token, "cad="): - value, err = parseMetric(token, "cad") - sec.cadence = value - case strings.HasPrefix(token, "w="): - value, err = parseMetric(token, "w") - sec.power = value - default: - return section{}, fmt.Errorf("ridetest: section %q: unknown metric %q (want hr=, cad= or w=)", raw, token) - } - if err != nil { - return section{}, fmt.Errorf("ridetest: section %q: %w", raw, err) - } - } - return sec, nil -} - -func parseUnit(token, unit string) (float64, error) { - if !strings.HasSuffix(token, unit) { - return 0, fmt.Errorf("expected a value in %s, got %q", unit, token) - } - return strconv.ParseFloat(strings.TrimSuffix(token, unit), 64) -} - -func parseMetric(token, name string) (*float64, error) { - value, err := strconv.ParseFloat(strings.TrimPrefix(token, name+"="), 64) - if err != nil { - return nil, fmt.Errorf("invalid %s value %q", name, token) - } - return &value, nil -} - -// metricComplete reports whether a metric column is present (any section sets -// it) and complete (every section sets it). -func metricComplete(sections []section, get func(section) *float64) (present, complete bool) { - set := 0 - for _, sec := range sections { - if get(sec) != nil { - set++ - } - } - return set > 0, set == len(sections) -} - -func buildRide(sections []section, o options) ride.Ride { - points := buildPoints(sections, o) - distances := make([]float64, len(points)) - elevations := make([]float64, len(points)) - coords := make([]geodist.Coord, len(points)) - timestamps := make([]time.Time, len(points)) - var heartRates, cadences, powers []float64 - if sections[0].heartRate != nil { - heartRates = make([]float64, len(points)) - } - if sections[0].cadence != nil { - cadences = make([]float64, len(points)) - } - if sections[0].power != nil { - powers = make([]float64, len(points)) - } - for i, p := range points { - distances[i] = p.distanceM - elevations[i] = p.elevationM - coords[i] = p.coord - timestamps[i] = p.timestamp - if heartRates != nil { - heartRates[i] = *p.section.heartRate - } - if cadences != nil { - cadences[i] = *p.section.cadence - } - if powers != nil { - powers[i] = *p.section.power - } - } - return ride.FromColumnsWithMetrics(distances, elevations, coords, timestamps, heartRates, cadences, powers) -} - -func buildPoints(sections []section, o options) []point { - lat, lon := 0.0, 0.0 - if o.startCoord != nil { - lat, lon = o.startCoord.Lat, o.startCoord.Lon - } - // Coordinates derive from the total distance so that exact multiples of - // the per-kilometer heading stay exact; a running accumulation drifts - // after a few points. - coord := func(distanceM float64) geodist.Coord { - return geodist.Coord{Lat: lat + o.headingLat*(distanceM/1000), Lon: lon + o.headingLon*(distanceM/1000)} - } - points := make([]point, 0, len(sections)+1) - elevation := o.startElevM - timestamp := o.startTime - points = append(points, point{ - distanceM: 0, - elevationM: elevation, - coord: coord(0), - timestamp: timestamp, - section: sections[0], - }) - distance := 0.0 - for _, sec := range sections { - curSecDist := 0.0 - for curSecDist < sec.distanceM { - curSecDist += o.samplingM - elevation += o.samplingM * sec.slope - timestamp = timestamp.Add(time.Second) - points = append(points, point{ - distanceM: distance + curSecDist, - elevationM: elevation, - coord: coord(distance + curSecDist), - timestamp: timestamp, - section: sec, - }) - } - distance += curSecDist - } - return points -} diff --git a/internal/ridetest/ride_test.go b/internal/ridetest/ride_test.go index 65483f2..46bd632 100644 --- a/internal/ridetest/ride_test.go +++ b/internal/ridetest/ride_test.go @@ -12,30 +12,30 @@ func TestRideSingleSection(t *testing.T) { r, err := Ride("1km at 10%") require.NoError(t, err) - assert.Len(t, r.Distances(), 11) - assert.Equal(t, 0.0, r.Distances()[0]) - assert.Equal(t, 1000.0, r.Distances()[10]) - assert.Equal(t, 0.0, r.Elevations()[0]) - assert.Equal(t, 100.0, r.Elevations()[10]) + assert.Len(t, r.Distances, 11) + assert.Equal(t, 0.0, r.Distances[0]) + assert.Equal(t, 1000.0, r.Distances[10]) + assert.Equal(t, 0.0, r.Elevations[0]) + assert.Equal(t, 100.0, r.Elevations[10]) } func TestRideSectionsWithDescentAndFlat(t *testing.T) { r, err := Ride("0.5km at 10% | 0.5km at -10%") require.NoError(t, err) - assert.Len(t, r.Distances(), 11) - assert.Equal(t, 1000.0, r.Distances()[10]) - assert.Equal(t, 50.0, r.Elevations()[5]) // top of the first section - assert.Equal(t, 0.0, r.Elevations()[10]) // back to start elevation + assert.Len(t, r.Distances, 11) + assert.Equal(t, 1000.0, r.Distances[10]) + assert.Equal(t, 50.0, r.Elevations[5]) // top of the first section + assert.Equal(t, 0.0, r.Elevations[10]) // back to start elevation } func TestRideSamplingOption(t *testing.T) { r, err := Ride("1km at 10%", Sampling(50)) require.NoError(t, err) - assert.Len(t, r.Distances(), 21) - assert.Equal(t, 1000.0, r.Distances()[20]) - assert.Equal(t, 5.0, r.Elevations()[1]) + assert.Len(t, r.Distances, 21) + assert.Equal(t, 1000.0, r.Distances[20]) + assert.Equal(t, 5.0, r.Elevations[1]) } func TestRideStartCoordHeading(t *testing.T) { @@ -44,13 +44,13 @@ func TestRideStartCoordHeading(t *testing.T) { // Coordinates advance northeast by 0.01° per km; float64 literals like // 43.11 drift in the last bits, so compare with a small tolerance. - require.Len(t, r.Coords(), 3) - assert.InDelta(t, 43.10, r.Coords()[0].Lat, 1e-9) - assert.InDelta(t, 5.10, r.Coords()[0].Lon, 1e-9) - assert.InDelta(t, 43.11, r.Coords()[1].Lat, 1e-9) - assert.InDelta(t, 5.11, r.Coords()[1].Lon, 1e-9) - assert.InDelta(t, 43.12, r.Coords()[2].Lat, 1e-9) - assert.InDelta(t, 5.12, r.Coords()[2].Lon, 1e-9) + require.Len(t, r.Coords, 3) + assert.InDelta(t, 43.10, r.Coords[0].Lat, 1e-9) + assert.InDelta(t, 5.10, r.Coords[0].Lon, 1e-9) + assert.InDelta(t, 43.11, r.Coords[1].Lat, 1e-9) + assert.InDelta(t, 5.11, r.Coords[1].Lon, 1e-9) + assert.InDelta(t, 43.12, r.Coords[2].Lat, 1e-9) + assert.InDelta(t, 5.12, r.Coords[2].Lon, 1e-9) } func TestRideHeadingOption(t *testing.T) { @@ -59,9 +59,9 @@ func TestRideHeadingOption(t *testing.T) { // A single kilometer at 0.1°/km ends at (43.1, 5.1), matching rides whose // points are spaced 0.1° apart over a kilometer. - require.Len(t, r.Coords(), 2) - assert.InDelta(t, 43.1, r.Coords()[1].Lat, 1e-9) - assert.InDelta(t, 5.1, r.Coords()[1].Lon, 1e-9) + require.Len(t, r.Coords, 2) + assert.InDelta(t, 43.1, r.Coords[1].Lat, 1e-9) + assert.InDelta(t, 5.1, r.Coords[1].Lon, 1e-9) } func TestRideStartTime(t *testing.T) { @@ -79,8 +79,8 @@ func TestRideStartElevation(t *testing.T) { r, err := Ride("1km at 0%", StartElevation(100)) require.NoError(t, err) - assert.Equal(t, 100.0, r.Elevations()[0]) - assert.Equal(t, 100.0, r.Elevations()[10]) + assert.Equal(t, 100.0, r.Elevations[0]) + assert.Equal(t, 100.0, r.Elevations[10]) } func TestRideMetricsCompleteColumns(t *testing.T) { diff --git a/internal/routetest/routetest.go b/internal/routetest/routetest.go new file mode 100644 index 0000000..a116608 --- /dev/null +++ b/internal/routetest/routetest.go @@ -0,0 +1,331 @@ +// Package routetest builds climb.Route test fixtures from a compact string DSL +// describing climbs and descents. ridetest reuses it and layers ride timing and +// telemetry on top, so climb tests do not depend on the ride package. +// +// Grammar: sections separated by "|", each "km at %" with +// optional per-section metrics "hr=150 cad=80 w=200": +// +// routetest.MustRoute("2km at 7% | 1km at -7%") +// +// Options adjust the generated columns: Sampling (step in meters, default +// 100), StartCoord (first point; later points progress northeast at the +// Heading rate, default 0.01° of latitude and longitude per kilometer), +// StartTime (default the zero time, one second between points) and +// StartElevation (default 0). +package routetest + +import ( + "fmt" + "strconv" + "strings" + "time" + + "github.com/jftuga/geodist" + "github.com/martinlehoux/biking_home/climb" +) + +const defaultSamplingM = 100 + +// headingPerKm is the latitude and longitude offset applied to each point per +// kilometer of ride, giving StartCoord rides a fixed northeast direction. +const headingPerKm = 0.01 + +type section struct { + distanceM float64 + slope float64 + heartRate *float64 + cadence *float64 + power *float64 +} + +type point struct { + distanceM float64 + elevationM float64 + coord geodist.Coord + timestamp time.Time + section section +} + +type options struct { + samplingM float64 + startCoord *geodist.Coord + headingLat float64 + headingLon float64 + startTime time.Time + startElevM float64 +} + +// Option configures the route generated by Build. +type Option func(*options) + +// Sampling sets the distance between consecutive points in meters +// (default 100). +func Sampling(m float64) Option { + return func(o *options) { o.samplingM = m } +} + +// StartCoord sets the coordinate of the first point. Each following point +// progresses northeast at the rate set by Heading, default 0.01° of latitude +// and longitude per kilometer. +func StartCoord(lat, lon float64) Option { + return func(o *options) { + o.startCoord = &geodist.Coord{Lat: lat, Lon: lon} + if o.headingLat == 0 { + o.headingLat = headingPerKm + } + if o.headingLon == 0 { + o.headingLon = headingPerKm + } + } +} + +// Heading sets the latitude and longitude offset applied to each point per +// kilometer of ride, in degrees (default 0.01 northeast when StartCoord is +// set). Only meaningful together with StartCoord. +func Heading(latPerKm, lonPerKm float64) Option { + return func(o *options) { + o.headingLat = latPerKm + o.headingLon = lonPerKm + } +} + +// StartTime sets the timestamp of the first point; each following point is +// one second later (default: the zero time). +func StartTime(t time.Time) Option { + return func(o *options) { o.startTime = t } +} + +// StartElevation sets the elevation of the first point, in meters (default 0). +func StartElevation(m float64) Option { + return func(o *options) { o.startElevM = m } +} + +// Built is the fully sampled output of a description: the geometry columns, +// timestamps, and, when every section provides them, the metric columns. +type Built struct { + Distances []float64 + Elevations []float64 + Coords []geodist.Coord + Timestamps []time.Time + HeartRates []float64 + Cadences []float64 + Powers []float64 +} + +// Build parses a description into sampled columns. Metrics (heart rate, +// cadence, power) are non-nil only when every section sets them. +func Build(dsl string, opts ...Option) (Built, error) { + o := options{samplingM: defaultSamplingM, headingLat: headingPerKm, headingLon: headingPerKm} + for _, opt := range opts { + opt(&o) + } + if o.samplingM <= 0 { + return Built{}, fmt.Errorf("routetest: sampling must be positive, got %v", o.samplingM) + } + sections, err := parseSections(dsl) + if err != nil { + return Built{}, err + } + if len(sections) == 0 { + return Built{}, fmt.Errorf("routetest: empty ride description") + } + for _, metric := range []struct { + name string + get func(section) *float64 + }{ + {"hr", func(s section) *float64 { return s.heartRate }}, + {"cad", func(s section) *float64 { return s.cadence }}, + {"w", func(s section) *float64 { return s.power }}, + } { + present, complete := metricComplete(sections, metric.get) + if present && !complete { + return Built{}, fmt.Errorf("routetest: %s= must be set on every section or none", metric.name) + } + } + points := buildPoints(sections, o) + built := Built{ + Distances: make([]float64, len(points)), + Elevations: make([]float64, len(points)), + Coords: make([]geodist.Coord, len(points)), + Timestamps: make([]time.Time, len(points)), + } + if sections[0].heartRate != nil { + built.HeartRates = make([]float64, len(points)) + } + if sections[0].cadence != nil { + built.Cadences = make([]float64, len(points)) + } + if sections[0].power != nil { + built.Powers = make([]float64, len(points)) + } + for i, p := range points { + built.Distances[i] = p.distanceM + built.Elevations[i] = p.elevationM + built.Coords[i] = p.coord + built.Timestamps[i] = p.timestamp + if built.HeartRates != nil { + built.HeartRates[i] = *p.section.heartRate + } + if built.Cadences != nil { + built.Cadences[i] = *p.section.cadence + } + if built.Powers != nil { + built.Powers[i] = *p.section.power + } + } + return built, nil +} + +// Route parses a description into a climb.Route containing only its geometry. +func Route(dsl string, opts ...Option) (climb.Route, error) { + built, err := Build(dsl, opts...) + if err != nil { + return climb.Route{}, err + } + return climb.Route{ + Distances: built.Distances, + Elevations: built.Elevations, + Coords: built.Coords, + }, nil +} + +// MustRoute is like Route but panics on a malformed description; use it inside +// tests where the description is a static program constant. +func MustRoute(dsl string, opts ...Option) climb.Route { + route, err := Route(dsl, opts...) + if err != nil { + panic(err) + } + return route +} + +func parseSections(dsl string) ([]section, error) { + rawSections := strings.Split(dsl, "|") + sections := make([]section, 0, len(rawSections)) + for _, raw := range rawSections { + raw = strings.TrimSpace(raw) + if raw == "" { + return nil, fmt.Errorf("routetest: empty section in %q", dsl) + } + sec, err := parseSection(raw) + if err != nil { + return nil, err + } + sections = append(sections, sec) + } + return sections, nil +} + +// parseSection parses a single section like "2km at 7% hr=150 cad=80 w=200". +func parseSection(raw string) (section, error) { + fields := strings.Fields(raw) + if len(fields) < 3 { + return section{}, fmt.Errorf("routetest: section %q must look like \"2km at 7%%\"", raw) + } + if fields[1] != "at" { + return section{}, fmt.Errorf("routetest: section %q: expected \"at\" between distance and slope, got %q", raw, fields[1]) + } + distanceKm, err := parseUnit(fields[0], "km") + if err != nil { + return section{}, fmt.Errorf("routetest: section %q: %w", raw, err) + } + slopePercent, err := parseUnit(fields[2], "%") + if err != nil { + return section{}, fmt.Errorf("routetest: section %q: %w", raw, err) + } + if distanceKm <= 0 { + return section{}, fmt.Errorf("routetest: section %q: distance must be positive", raw) + } + sec := section{ + distanceM: distanceKm * 1000, + slope: slopePercent / 100, + } + for _, token := range fields[3:] { + var value *float64 + switch { + case strings.HasPrefix(token, "hr="): + value, err = parseMetric(token, "hr") + sec.heartRate = value + case strings.HasPrefix(token, "cad="): + value, err = parseMetric(token, "cad") + sec.cadence = value + case strings.HasPrefix(token, "w="): + value, err = parseMetric(token, "w") + sec.power = value + default: + return section{}, fmt.Errorf("routetest: section %q: unknown metric %q (want hr=, cad= or w=)", raw, token) + } + if err != nil { + return section{}, fmt.Errorf("routetest: section %q: %w", raw, err) + } + } + return sec, nil +} + +func parseUnit(token, unit string) (float64, error) { + if !strings.HasSuffix(token, unit) { + return 0, fmt.Errorf("expected a value in %s, got %q", unit, token) + } + return strconv.ParseFloat(strings.TrimSuffix(token, unit), 64) +} + +func parseMetric(token, name string) (*float64, error) { + value, err := strconv.ParseFloat(strings.TrimPrefix(token, name+"="), 64) + if err != nil { + return nil, fmt.Errorf("invalid %s value %q", name, token) + } + return &value, nil +} + +// metricComplete reports whether a metric column is present (any section sets +// it) and complete (every section sets it). +func metricComplete(sections []section, get func(section) *float64) (present, complete bool) { + set := 0 + for _, sec := range sections { + if get(sec) != nil { + set++ + } + } + return set > 0, set == len(sections) +} + +func buildPoints(sections []section, o options) []point { + lat, lon := 0.0, 0.0 + if o.startCoord != nil { + lat, lon = o.startCoord.Lat, o.startCoord.Lon + } + // Coordinates derive from the total distance so that exact multiples of + // the per-kilometer heading stay exact; a running accumulation drifts + // after a few points. + coord := func(distanceM float64) geodist.Coord { + return geodist.Coord{Lat: lat + o.headingLat*(distanceM/1000), Lon: lon + o.headingLon*(distanceM/1000)} + } + points := make([]point, 0, len(sections)+1) + elevation := o.startElevM + timestamp := o.startTime + points = append(points, point{ + distanceM: 0, + elevationM: elevation, + coord: coord(0), + timestamp: timestamp, + section: sections[0], + }) + distance := 0.0 + for _, sec := range sections { + curSecDist := 0.0 + for curSecDist < sec.distanceM { + curSecDist += o.samplingM + elevation += o.samplingM * sec.slope + timestamp = timestamp.Add(time.Second) + points = append(points, point{ + distanceM: distance + curSecDist, + elevationM: elevation, + coord: coord(distance + curSecDist), + timestamp: timestamp, + section: sec, + }) + } + distance += curSecDist + } + return points +} diff --git a/mountain_pass/detection.go b/mountain_pass/detection.go index 40281f3..79e55e4 100644 --- a/mountain_pass/detection.go +++ b/mountain_pass/detection.go @@ -5,7 +5,8 @@ import ( "math" "github.com/jftuga/geodist" - "github.com/martinlehoux/biking_home/ride" + "github.com/martinlehoux/biking_home/climb" + "github.com/martinlehoux/biking_home/geo" ) // MountainPass is a pass extracted from OpenStreetMap: a node tagged @@ -112,9 +113,9 @@ func DetectCrossings(coords []geodist.Coord, distances, elevations []float64, pa // MatchClimb returns the pass whose coordinates lie within radiusM of the // climb's highest point and whose elevation is within elevationToleranceM of // that point's elevation, nearest first. Found is false when no pass matches. -func MatchClimb(climb ride.Climb, passes []MountainPass, radiusM, elevationToleranceM float64) (MountainPass, bool) { - topCoord := climb.TopCoord() - topElevation := climb.TopElevationM() +func MatchClimb(segment climb.Climb, passes []MountainPass, radiusM, elevationToleranceM float64) (MountainPass, bool) { + topIndex, topElevation := segment.Top() + topCoord := segment.Coords[topIndex] var best MountainPass bestDistanceM := radiusM found := false @@ -139,21 +140,17 @@ func MatchClimb(climb ride.Climb, passes []MountainPass, radiusM, elevationToler } func nearestCrossing(coords []geodist.Coord, distances, elevations []float64, mountainPass MountainPass) (Crossing, bool) { - best := Crossing{Pass: mountainPass, DistanceToM: 1e18} - for i := range coords { - distanceKm, _ := geodist.HaversineDistance(coords[i], *mountainPass.Coord) - distanceM := distanceKm * 1000 - if distanceM < best.DistanceToM { - best.DistanceToM = distanceM - best.RideDistanceM = distances[i] - best.RideElevation = elevations[i] - best.ElevationDiff = absFloat64(elevations[i] - float64(mountainPass.Elevation)) - } - } - if best.DistanceToM > 1e17 { - return best, false + index, distanceM := geo.Nearest(coords, *mountainPass.Coord) + if math.IsInf(distanceM, 1) { + return Crossing{Pass: mountainPass}, false } - return best, true + return Crossing{ + Pass: mountainPass, + DistanceToM: distanceM, + RideDistanceM: distances[index], + RideElevation: elevations[index], + ElevationDiff: absFloat64(elevations[index] - float64(mountainPass.Elevation)), + }, true } func absFloat64(value float64) float64 { diff --git a/mountain_pass/detection_test.go b/mountain_pass/detection_test.go index 6318ca3..6b297b5 100644 --- a/mountain_pass/detection_test.go +++ b/mountain_pass/detection_test.go @@ -5,8 +5,10 @@ import ( "testing" "github.com/jftuga/geodist" + "github.com/martinlehoux/biking_home/climb" "github.com/martinlehoux/biking_home/internal/dbtest" "github.com/martinlehoux/biking_home/internal/ridetest" + "github.com/martinlehoux/biking_home/internal/routetest" "github.com/martinlehoux/biking_home/mountain_pass" "github.com/martinlehoux/biking_home/ride" "github.com/stretchr/testify/assert" @@ -19,7 +21,7 @@ func TestDetectCrossingsFindsPass(t *testing.T) { Name: "Pas de Magnan", Elevation: 460, Coord: &geodist.Coord{Lat: 43.62, Lon: 5.43}, } - crossings := mountain_pass.DetectCrossings(r.Coords(), r.Distances(), r.Elevations(), []mountain_pass.MountainPass{pass}, 100, 25) + crossings := mountain_pass.DetectCrossings(r.Coords, r.Distances, r.Elevations, []mountain_pass.MountainPass{pass}, 100, 25) assert.Len(t, crossings, 1) assert.Equal(t, "Pas de Magnan", crossings[0].Pass.Name) assert.Less(t, crossings[0].DistanceToM, 10.0) @@ -42,7 +44,7 @@ func TestLoadMountainPassesAroundRideUsesExpandedBounds(t *testing.T) { require.NoError(t, err) route := ridetest.MustRide("1km at 10%", ridetest.StartCoord(43.0, 5.0), ridetest.Heading(0.1, 0.1), ridetest.StartElevation(100), ridetest.Sampling(1000)) - passes, err := mountain_pass.LoadMountainPassesAroundRide(db, route.Coords(), 10_000) + passes, err := mountain_pass.LoadMountainPassesAroundRide(db, route.Coords, 10_000) require.NoError(t, err) require.Len(t, passes, 3) assert.Equal(t, "On route edge", passes[0].Name) @@ -77,13 +79,13 @@ func TestDetectCrossingsIgnoresDistantPass(t *testing.T) { Name: "Col de la Faucille", Elevation: 1320, Coord: &geodist.Coord{Lat: 46.37, Lon: 6.02}, } - assert.Empty(t, mountain_pass.DetectCrossings(r.Coords(), r.Distances(), r.Elevations(), []mountain_pass.MountainPass{far}, 100, 25)) + assert.Empty(t, mountain_pass.DetectCrossings(r.Coords, r.Distances, r.Elevations, []mountain_pass.MountainPass{far}, 100, 25)) } func TestDetectCrossingsIgnoresPassWithoutCoordinates(t *testing.T) { r := rideFromPoint(t, 43.62, 5.43, 447) noCoord := mountain_pass.MountainPass{Name: "Col de la Gatasse", Elevation: 120} - assert.Empty(t, mountain_pass.DetectCrossings(r.Coords(), r.Distances(), r.Elevations(), []mountain_pass.MountainPass{noCoord}, 100, 25)) + assert.Empty(t, mountain_pass.DetectCrossings(r.Coords, r.Distances, r.Elevations, []mountain_pass.MountainPass{noCoord}, 100, 25)) } func TestDetectCrossingsRequiresElevationAgreement(t *testing.T) { @@ -92,7 +94,7 @@ func TestDetectCrossingsRequiresElevationAgreement(t *testing.T) { Name: "Pas de Magnan", Elevation: 900, Coord: &geodist.Coord{Lat: 43.62, Lon: 5.43}, } - assert.Empty(t, mountain_pass.DetectCrossings(r.Coords(), r.Distances(), r.Elevations(), []mountain_pass.MountainPass{pass}, 100, 25)) + assert.Empty(t, mountain_pass.DetectCrossings(r.Coords, r.Distances, r.Elevations, []mountain_pass.MountainPass{pass}, 100, 25)) } // rideFromPoint builds a 1 km ride ending at (latitude, longitude) at the @@ -144,10 +146,10 @@ func TestMatchClimbNearestOfTwo(t *testing.T) { // climbFixture builds a 2 km climb from 300 m up to 447 m then back down to // 380 m, heading northeast from (43.61, 5.42). -func climbFixture() ride.Climb { - r := ridetest.MustRide("1km at 14.7% | 1km at -6.7%", - ridetest.StartCoord(43.61, 5.42), - ridetest.StartElevation(300), - ridetest.Sampling(1000)) - return r.ClimbFromDist(0, 2000) +func climbFixture() climb.Climb { + route := routetest.MustRoute("1km at 14.7% | 1km at -6.7%", + routetest.StartCoord(43.61, 5.42), + routetest.StartElevation(300), + routetest.Sampling(1000)) + return climb.Climb{Route: route} } diff --git a/official_climb/official_climb_test.go b/official_climb/official_climb_test.go index ed121e5..6ddce5a 100644 --- a/official_climb/official_climb_test.go +++ b/official_climb/official_climb_test.go @@ -56,10 +56,10 @@ func TestMatchClimbMatchesWithoutDetectedClimb(t *testing.T) { parsed := ridetest.MustRide("2km at 5% | 1km at -10%", ridetest.StartCoord(43.10, 5.10), ridetest.StartElevation(100), ridetest.Sampling(1000)) official := official_climb.OfficialClimb{ Name: "On route", - StartCoord: parsed.Coords()[1], - EndCoord: parsed.Coords()[2], + StartCoord: parsed.Coords[1], + EndCoord: parsed.Coords[2], } - matched, found := official_climb.MatchClimb(parsed.Coords(), official, official_climb.DefaultMatchRadiusM) + matched, found := official_climb.MatchClimb(parsed.Coords, official, official_climb.DefaultMatchRadiusM) require.True(t, found) assert.Equal(t, 1, matched.StartIndex) assert.Equal(t, 2, matched.EndIndex) @@ -72,7 +72,7 @@ func TestMatchClimbMatchesEndpointInsideRadius(t *testing.T) { StartCoord: geodist.Coord{Lat: 43.1101, Lon: 5.1101}, EndCoord: geodist.Coord{Lat: 43.1201, Lon: 5.1201}, } - matched, found := official_climb.MatchClimb(parsed.Coords(), official, official_climb.DefaultMatchRadiusM) + matched, found := official_climb.MatchClimb(parsed.Coords, official, official_climb.DefaultMatchRadiusM) require.True(t, found) assert.Equal(t, 1, matched.StartIndex) assert.Equal(t, 2, matched.EndIndex) @@ -82,10 +82,10 @@ func TestMatchClimbRejectsReversedEndpoints(t *testing.T) { parsed := ridetest.MustRide("2km at 5% | 1km at -10%", ridetest.StartCoord(43.10, 5.10), ridetest.StartElevation(100), ridetest.Sampling(1000)) official := official_climb.OfficialClimb{ Name: "Reversed", - StartCoord: parsed.Coords()[2], - EndCoord: parsed.Coords()[1], + StartCoord: parsed.Coords[2], + EndCoord: parsed.Coords[1], } - _, found := official_climb.MatchClimb(parsed.Coords(), official, official_climb.DefaultMatchRadiusM) + _, found := official_climb.MatchClimb(parsed.Coords, official, official_climb.DefaultMatchRadiusM) assert.False(t, found) } @@ -94,9 +94,9 @@ func TestMatchClimbRejectsEndpointOutsideRadius(t *testing.T) { official := official_climb.OfficialClimb{ Name: "Too far", StartCoord: geodist.Coord{Lat: 43.09, Lon: 5.09}, - EndCoord: parsed.Coords()[2], + EndCoord: parsed.Coords[2], } - _, found := official_climb.MatchClimb(parsed.Coords(), official, 10) + _, found := official_climb.MatchClimb(parsed.Coords, official, 10) assert.False(t, found) } @@ -122,14 +122,14 @@ func TestMatchClimbsMatchesTwoLaps(t *testing.T) { StartCoord: geodist.Coord{Lat: 43.10, Lon: 5.10}, EndCoord: geodist.Coord{Lat: 43.12, Lon: 5.12}, } - matches := official_climb.MatchClimbs(parsed.Coords(), official, official_climb.DefaultMatchRadiusM) + matches := official_climb.MatchClimbs(parsed.Coords, official, official_climb.DefaultMatchRadiusM) require.Len(t, matches, 2) assert.Equal(t, 0, matches[0].StartIndex) assert.Equal(t, 2, matches[0].EndIndex) assert.Equal(t, 4, matches[1].StartIndex) assert.Equal(t, 6, matches[1].EndIndex) // Single-match helper still returns first occurrence for backward compatibility. - single, found := official_climb.MatchClimb(parsed.Coords(), official, official_climb.DefaultMatchRadiusM) + single, found := official_climb.MatchClimb(parsed.Coords, official, official_climb.DefaultMatchRadiusM) require.True(t, found) assert.Equal(t, 0, single.StartIndex) } @@ -154,7 +154,7 @@ func TestMatchClimbsMatchesOnlyCompleteLaps(t *testing.T) { StartCoord: geodist.Coord{Lat: 43.10, Lon: 5.10}, EndCoord: geodist.Coord{Lat: 43.12, Lon: 5.12}, } - matches := official_climb.MatchClimbs(parsed.Coords(), official, official_climb.DefaultMatchRadiusM) + matches := official_climb.MatchClimbs(parsed.Coords, official, official_climb.DefaultMatchRadiusM) require.Len(t, matches, 1) assert.Equal(t, 3, matches[0].StartIndex) assert.Equal(t, 5, matches[0].EndIndex) @@ -177,7 +177,7 @@ func TestMatchClimbsPreservesDirectionAcrossLaps(t *testing.T) { StartCoord: geodist.Coord{Lat: 43.10, Lon: 5.10}, EndCoord: geodist.Coord{Lat: 43.12, Lon: 5.12}, } - matches := official_climb.MatchClimbs(parsed.Coords(), official, official_climb.DefaultMatchRadiusM) + matches := official_climb.MatchClimbs(parsed.Coords, official, official_climb.DefaultMatchRadiusM) assert.Empty(t, matches) } @@ -203,7 +203,7 @@ func TestMatchClimbsClustersContiguousPoints(t *testing.T) { StartCoord: geodist.Coord{Lat: 43.10, Lon: 5.10}, EndCoord: geodist.Coord{Lat: 43.12, Lon: 5.12}, } - matches := official_climb.MatchClimbs(parsed.Coords(), official, official_climb.DefaultMatchRadiusM) + matches := official_climb.MatchClimbs(parsed.Coords, official, official_climb.DefaultMatchRadiusM) require.Len(t, matches, 2) assert.Equal(t, 0, matches[0].StartIndex) assert.Equal(t, 4, matches[0].EndIndex) diff --git a/ride/climb.go b/ride/climb.go deleted file mode 100644 index 013104e..0000000 --- a/ride/climb.go +++ /dev/null @@ -1,329 +0,0 @@ -package ride - -import ( - "fmt" - "log/slog" - "math" - "slices" - "time" - - "github.com/jftuga/geodist" - "github.com/martinlehoux/kagamigo/kcore" -) - -const ClimbDistanceMinimum = 500 - -// ClimbSlopeMinimum is the minimum average slope (ratio) of a detected climb; -// gentler regions are re-segmented into their qualifying steeper parts. -const ClimbSlopeMinimum = 0.035 - -// Category is the difficulty class of a climb, ordered from easiest to -// hardest. -type Category int - -const ( - CategoryNo Category = iota - CategoryFour - CategoryThree - CategoryTwo - CategoryOne - CategoryHC -) - -type categoryInfo struct { - label string - minScore float64 -} - -// categories describes each Category: its display label and the inclusive -// Cotacol score floor at which it starts applying. A score at or above -// CategoryOne's floor is Hors Catégorie; CategoryNo has no floor. -var categories = map[Category]categoryInfo{ - CategoryNo: {label: "NO", minScore: math.Inf(-1)}, - CategoryFour: {label: "Cat 4", minScore: 35}, - CategoryThree: {label: "Cat 3", minScore: 80}, - CategoryTwo: {label: "Cat 2", minScore: 180}, - CategoryOne: {label: "Cat 1", minScore: 250}, - CategoryHC: {label: "HC", minScore: 600}, -} - -// climbDetectionMinimumScore is the climb-detection score below which a -// candidate segment is not reported as a climb. -const climbDetectionMinimumScore = 35 - -type Climb struct { - distances []float64 - elevations []float64 - coords []geodist.Coord - timestamps []time.Time - rideStart int - rideEnd int - Name string -} - -func (climb Climb) Duration() time.Duration { - return climb.timestamps[climb.rideEnd].Sub(climb.timestamps[climb.rideStart]) -} - -func (climb Climb) Speed() float64 { - return (climb.EndDistanceM() - climb.StartDistanceM()) / climb.Duration().Seconds() -} - -func (climb Climb) StartIndex() int { return climb.rideStart } - -func (climb Climb) EndIndex() int { return climb.rideEnd } - -func (climb Climb) StartDistanceM() float64 { - return climb.distances[climb.rideStart] -} - -func (climb Climb) EndDistanceM() float64 { - return climb.distances[climb.rideEnd] -} - -func (climb Climb) StartCoord() geodist.Coord { - return climb.coords[climb.rideStart] -} - -func (climb Climb) EndCoord() geodist.Coord { - return climb.coords[climb.rideEnd] -} - -func (climb Climb) PointCoord(index int) geodist.Coord { - kcore.Assert(index >= climb.rideStart && index <= climb.rideEnd, "point is outside climb") - return climb.coords[index] -} - -// TopIndex returns the highest point inside the climb, used as the crest anchor. -func (climb Climb) TopIndex() int { - top := climb.rideStart - for i := climb.rideStart; i <= climb.rideEnd; i++ { - if climb.elevations[i] > climb.elevations[top] { - top = i - } - } - return top -} - -func (climb Climb) TopDistanceM() float64 { - return climb.distances[climb.TopIndex()] -} - -func (climb Climb) TopElevationM() float64 { - return climb.elevations[climb.TopIndex()] -} - -func (climb Climb) TopCoord() geodist.Coord { - return climb.coords[climb.TopIndex()] -} - -func (climb Climb) String() string { - startDistance := climb.StartDistanceM() - endDistance := climb.EndDistanceM() - cotacol := climb.DifficultyScore() - body := fmt.Sprintf("%.1fkm-%.1fkm: %.1fkm at %.1f%% (%d pts - %s)", startDistance/1000, endDistance/1000, (endDistance-startDistance)/1000, Slope(climb.distances, climb.elevations, climb.rideStart, climb.rideEnd)*100, int(cotacol), CategoryOf(cotacol)) - if climb.Name == "" { - return body - } - return climb.Name + ": " + body -} - -func (climb Climb) DifficultyScore() float64 { - return difficultyScore(climb.distances, climb.elevations, climb.rideStart, climb.rideEnd) -} - -func Slope(distances, elevations []float64, start, end int) float64 { - return (elevations[end] - elevations[start]) / (distances[end] - distances[start]) -} - -func climbDetectionScore(distances, elevations []float64, start, end int) float64 { - kcore.Assert(end > start, "no points for climb detection") - distance := distances[end] - distances[start] - if distance == 0 { - return 0 - } - dElevation := elevations[end] - elevations[start] - return math.Abs(dElevation) * dElevation / distance * 100.0 * 100.0 / 1000.0 -} - -func (c Category) String() string { - return categories[c].label -} - -// CategoryOf returns the difficulty class of a Cotacol score: the category -// with the highest floor the score reaches. -func CategoryOf(cotacol float64) Category { - best := CategoryNo - bestMin := math.Inf(-1) - found := false - for category, info := range categories { - if cotacol >= info.minScore && (!found || info.minScore > bestMin) { - best = category - bestMin = info.minScore - found = true - } - } - if !found { - // NaN reaches no floor; preserve the original switch default (HC). - return CategoryHC - } - return best -} - -type interval struct { - start int - end int -} - -// bestClimbBetween returns the highest-scoring climb in [start, end]; when -// that climb averages below minSlope, it instead returns the best qualifying -// sub-segment (found=false when none exists). -func bestClimbBetween(distances, elevations []float64, start, end int, minSlope float64) (interval, bool) { - kcore.Assert(end > start, "empty points") - - bestDetectionScore := climbDetectionScore(distances, elevations, start, end) - bestStart := start - for i := start; i < end; i++ { - detectionScore := climbDetectionScore(distances, elevations, i, end) - if detectionScore > bestDetectionScore { - bestStart = i - bestDetectionScore = detectionScore - } - } - bestEnd := end - for i := end; i > bestStart; i-- { - detectionScore := climbDetectionScore(distances, elevations, bestStart, i) - if detectionScore > bestDetectionScore { - bestEnd = i - bestDetectionScore = detectionScore - } - } - for i := bestStart; i < bestEnd; i++ { - detectionScore := climbDetectionScore(distances, elevations, i, bestEnd) - if detectionScore > bestDetectionScore { - bestStart = i - bestDetectionScore = detectionScore - } - } - kcore.Assert(bestStart < bestEnd, "empty climb") - if minSlope <= 0 || Slope(distances, elevations, bestStart, bestEnd) >= minSlope { - return interval{bestStart, bestEnd}, true - } - // Classic best too gentle: re-segment into the best qualifying sub-segment. - bestDetectionScore = math.Inf(-1) - bestStart, bestEnd = -1, -1 - for i := start; i < end; i++ { - for j := i + 1; j <= end; j++ { - if Slope(distances, elevations, i, j) < minSlope { - continue - } - detectionScore := climbDetectionScore(distances, elevations, i, j) - if detectionScore > bestDetectionScore { - bestDetectionScore = detectionScore - bestStart, bestEnd = i, j - } - } - } - if bestStart < 0 { - return interval{}, false - } - return interval{bestStart, bestEnd}, true -} - -// climbsBetween returns the detected climb bounds in [start, end] from the -// distance and elevation components. -func climbsBetween(distances, elevations []float64, start, end int, minSlope float64) []interval { - climbs := []interval{} - if distances[end]-distances[start] < ClimbDistanceMinimum { - return climbs - } - slog.Debug("Searching climbs between", slog.Int("start", int(distances[start])), slog.Int("end", int(distances[end]))) - highest := start - for i := start; i <= end; i++ { - if elevations[i] > elevations[highest] { - highest = i - } - } - // TODO: Use descent to reduce recursion - if distances[highest]-distances[start] < ClimbDistanceMinimum { - return climbsBetween(distances, elevations, start+1, end, minSlope) - } - climb, found := bestClimbBetween(distances, elevations, start, highest, minSlope) - if !found { - // No qualifying climb up to the top; only the part past it can hold climbs. - return climbsBetween(distances, elevations, highest, end, minSlope) - } - if climbDetectionScore(distances, elevations, climb.start, climb.end) >= climbDetectionMinimumScore && distances[climb.end]-distances[climb.start] >= ClimbDistanceMinimum { - slog.Debug("Found climb between", slog.Int("start", int(distances[climb.start])), slog.Int("end", int(distances[climb.end]))) - climbs = append(climbs, climb) - } - climbs = append(climbs, climbsBetween(distances, elevations, start, climb.start, minSlope)...) - climbs = append(climbs, climbsBetween(distances, elevations, climb.end, end, minSlope)...) - - return climbs -} - -type Range struct { - Start int - End int -} - -func isValidClaim(claim Range, routeLen int) bool { - return claim.Start >= 0 && claim.End < routeLen && claim.Start < claim.End -} - -// freeRegions returns the maximal sub-ranges of [rStart, rEnd] that are not -// covered by any claimed range, after discarding claims that fall outside. -func freeRegions(rStart, rEnd int, claimed []Range) []Range { - valid := make([]Range, 0, len(claimed)) - for _, claim := range claimed { - if !isValidClaim(claim, rEnd+1) { - continue - } - valid = append(valid, claim) - } - slices.SortFunc(valid, func(a, b Range) int { return a.Start - b.Start }) - regions := make([]Range, 0, len(valid)+1) - current := rStart - for _, claim := range valid { - if claim.End < current { - continue - } - if claim.Start > current { - regions = append(regions, Range{Start: current, End: claim.Start - 1}) - } - if claim.End+1 > current { - current = claim.End + 1 - } - } - if current <= rEnd { - regions = append(regions, Range{Start: current, End: rEnd}) - } - return regions -} - -// returns the detected climbs of the whole ride, but never a climb that overlaps -// a claimed range. -func (ride *Ride) DetectClimbs(claimed []Range, minSlope float64) []Climb { - bounds := make([]interval, 0) - for _, region := range freeRegions(0, ride.Len()-1, claimed) { - bounds = append(bounds, climbsBetween(ride.distances, ride.elevations, region.Start, region.End, minSlope)...) - } - slices.SortFunc(bounds, func(a, b interval) int { return a.start - b.start }) - climbs := make([]Climb, 0, len(bounds)) - for _, b := range bounds { - climbs = append(climbs, climbFromBounds(ride, b)) - } - return climbs -} - -func climbFromBounds(ride *Ride, b interval) Climb { - return Climb{ - distances: ride.distances, - elevations: ride.elevations, - coords: ride.coords, - timestamps: ride.timestamps, - rideStart: b.start, - rideEnd: b.end, - } -} diff --git a/ride/climb_test.go b/ride/climb_test.go deleted file mode 100644 index 3632da7..0000000 --- a/ride/climb_test.go +++ /dev/null @@ -1,135 +0,0 @@ -package ride_test - -import ( - "testing" - - "github.com/martinlehoux/biking_home/internal/ridetest" - "github.com/martinlehoux/biking_home/ride" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -var parser = ride.GPXRideParser{} - -func TestClimbThenFalseFlat(t *testing.T) { - r := ridetest.MustRide("2km at 7% | 10km at 1%") - climbs := r.DetectClimbs(nil, ride.ClimbSlopeMinimum) - - assert.Len(t, climbs, 1) - assert.Equal(t, "0.0km-2.0km: 2.0km at 7.0% (98 pts - Cat 3)", climbs[0].String()) -} - -func TestSmallClimbWithDescentInsideFalseFlat(t *testing.T) { - r := ridetest.MustRide("10km at 1% | 2km at 7% | 1km at -7% | 10km at 1%") - climbs := r.DetectClimbs(nil, ride.ClimbSlopeMinimum) - - assert.Len(t, climbs, 1) - assert.Equal(t, "10.0km-12.0km: 2.0km at 7.0% (98 pts - Cat 3)", climbs[0].String()) -} - -func TestPogacar20220721(t *testing.T) { - r, err := ride.ParseFile(parser, "../examples/2022-07-21.Pogacar.gpx") - assert.NoError(t, err) - climbs := r.DetectClimbs(nil, ride.ClimbSlopeMinimum) - - assert.Len(t, climbs, 6) - assert.Equal(t, "0.6km-5.6km: 5.0km at 3.5% (92 pts - Cat 3)", climbs[0].String()) - assert.Equal(t, "42.9km-45.2km: 2.3km at 5.0% (74 pts - Cat 4)", climbs[1].String()) - assert.Equal(t, "60.0km-76.4km: 16.4km at 7.2% (977 pts - HC)", climbs[2].String()) - assert.Equal(t, "83.8km-85.9km: 2.0km at 5.4% (79 pts - Cat 4)", climbs[3].String()) - assert.Equal(t, "99.2km-109.3km: 10.2km at 8.4% (751 pts - HC)", climbs[4].String()) - assert.Equal(t, "128.6km-142.2km: 13.6km at 7.8% (930 pts - HC)", climbs[5].String()) -} - -func TestBouclesVerdon2024(t *testing.T) { - r, err := ride.ParseFile(parser, "../examples/2024-06-29.BouclesVerdon.gpx") - assert.NoError(t, err) - climbs := r.DetectClimbs(nil, ride.ClimbSlopeMinimum) - - assert.Len(t, climbs, 6) - assert.Equal(t, "6.8km-9.1km: 2.3km at 4.0% (41 pts - Cat 4)", climbs[0].String()) - assert.Equal(t, "34.7km-37.3km: 2.5km at 5.0% (67 pts - Cat 4)", climbs[1].String()) - assert.Equal(t, "41.9km-43.8km: 1.8km at 4.6% (39 pts - Cat 4)", climbs[2].String()) - assert.Equal(t, "57.6km-60.8km: 3.1km at 6.3% (139 pts - Cat 3)", climbs[3].String()) - assert.Equal(t, "71.7km-74.7km: 3.0km at 5.3% (89 pts - Cat 3)", climbs[4].String()) - assert.Equal(t, "77.4km-78.7km: 1.3km at 5.3% (39 pts - Cat 4)", climbs[5].String()) -} - -func TestMimetArbois20241229(t *testing.T) { - r, err := ride.ParseFile(parser, "../examples/2024-12-29.MimetArbois.gpx") - assert.NoError(t, err) - climbs := r.DetectClimbs(nil, ride.ClimbSlopeMinimum) - - assert.Len(t, climbs, 2) - assert.Equal(t, "3.9km-5.3km: 1.4km at 10.3% (165 pts - Cat 3)", climbs[0].String()) - assert.Equal(t, "14.4km-19.8km: 5.4km at 4.3% (113 pts - Cat 3)", climbs[1].String()) -} - -func TestMimetArbois20250104(t *testing.T) { - r, err := ride.ParseFile(parser, "../examples/2025-01-04.MimetArbois.gpx") - assert.NoError(t, err) - climbs := r.DetectClimbs(nil, ride.ClimbSlopeMinimum) - - assert.Len(t, climbs, 2) - assert.Equal(t, "4.5km-5.9km: 1.4km at 10.1% (159 pts - Cat 3)", climbs[0].String()) - assert.Equal(t, "12.0km-15.1km: 3.2km at 4.5% (99 pts - Cat 3)", climbs[1].String()) -} - -// https://www.strava.com/activities/9282265844 -func TestAlpesVerdonTour20230617(t *testing.T) { - r, err := ride.ParseFile(parser, "../examples/2023-06-17.AlpesVerdonTour.gpx") - assert.NoError(t, err) - climbs := r.DetectClimbs(nil, ride.ClimbSlopeMinimum) - - assert.Len(t, climbs, 6) - assert.Equal(t, "0.7km-4.6km: 3.9km at 4.2% (72 pts - Cat 4)", climbs[0].String()) - assert.Equal(t, "8.2km-18.6km: 10.3km at 4.4% (292 pts - Cat 1)", climbs[1].String()) - assert.Equal(t, "36.1km-41.6km: 5.4km at 5.8% (193 pts - Cat 2)", climbs[2].String()) - assert.Equal(t, "47.0km-48.2km: 1.2km at 6.1% (45 pts - Cat 4)", climbs[3].String()) - assert.Equal(t, "51.9km-54.7km: 2.8km at 4.0% (48 pts - Cat 4)", climbs[4].String()) - assert.Equal(t, "114.1km-114.7km: 0.6km at 14.3% (119 pts - Cat 3)", climbs[5].String()) -} - -func TestDetectClimbsSkipsMiddleClaim(t *testing.T) { - // A climb, a false flat giving a saddle, then a climb again. Claiming the - // saddle's descent base should leave both climbs detected separately. - r := ridetest.MustRide("2km at 7% | 1km at -7% | 2km at 7%") - climbs := r.DetectClimbs([]ride.Range{{Start: 20, End: 20}}, ride.ClimbSlopeMinimum) - - // No free region falls under the min distance except the two climbs. - assert.Len(t, climbs, 2) - assert.Equal(t, "0.0km-2.0km: 2.0km at 7.0% (98 pts - Cat 3)", climbs[0].String()) - assert.Equal(t, "3.0km-5.0km: 2.0km at 7.0% (98 pts - Cat 3)", climbs[1].String()) -} - -func TestDetectClimbsOverlappingClaimDropsClimb(t *testing.T) { - // Claiming the ascent region entirely swallows the climb. - r := ridetest.MustRide("2km at 7% | 20km at 0%") - climbs := r.DetectClimbs([]ride.Range{{Start: 0, End: 40}}, ride.ClimbSlopeMinimum) - assert.Empty(t, climbs) -} - -func TestDetectClimbsIgnoresOutOfRangeClaim(t *testing.T) { - r := ridetest.MustRide("2km at 7% | 20km at 0%") - climbs := r.DetectClimbs([]ride.Range{{Start: -5, End: -1}}, ride.ClimbSlopeMinimum) - require.Len(t, climbs, 1) - assert.Equal(t, "0.0km-2.0km: 2.0km at 7.0% (98 pts - Cat 3)", climbs[0].String()) -} - -func TestDetectClimbsWithUnsortedClaimsDropsOverlappingClimb(t *testing.T) { - // Reproduces ride 6 Chemin des Lauriers bug: DetectClimbs must not depend on - // caller-provided order. Two official claims out of ID order (Bastides - // 2140-2627 before Lauriers 1215-1588) caused freeRegions to skip the - // earlier claim and leave the overlapping Cat 4 1352-1601 visible. - // Minimal reproduction: two climbs 0-2km and 3-5km separated by a descent. - r := ridetest.MustRide("2km at 7% | 1km at -7% | 2km at 7%") - // Both climbs claimed, but in reverse order (mimics ORDER BY id returning 30-50 before 0-20). - // Sorted input correctly yields 0 detected climbs. - sorted := []ride.Range{{Start: 0, End: 20}, {Start: 30, End: 50}} - assert.Empty(t, r.DetectClimbs(sorted, ride.ClimbSlopeMinimum)) - - unsorted := []ride.Range{{Start: 30, End: 50}, {Start: 0, End: 20}} - climbs := r.DetectClimbs(unsorted, ride.ClimbSlopeMinimum) - // Before fix this fails: freeRegions skips {0,20} because 20 < 30 and returns 1 climb. - assert.Empty(t, climbs, "unsorted claimed ranges must still suppress overlapping climbs — expected 0, got %v", climbs) -} diff --git a/ride/detection_example_test.go b/ride/detection_example_test.go new file mode 100644 index 0000000..053abbb --- /dev/null +++ b/ride/detection_example_test.go @@ -0,0 +1,87 @@ +package ride_test + +import ( + "fmt" + "testing" + + "github.com/martinlehoux/biking_home/climb" + "github.com/martinlehoux/biking_home/ride" + "github.com/stretchr/testify/assert" +) + +var parser = ride.GPXRideParser{} + +// describeClimb renders a detected climb as the ride-style +// "start-end: length at slope (pts - cat)" string, so positions are asserted. +func describeClimb(route climb.Route, detected climb.DetectedClimb) string { + seg := detected.Climb + startKm := route.Distances[detected.Start] / 1000 + endKm := route.Distances[detected.Start+len(seg.Distances)-1] / 1000 + lengthKm := (seg.Distances[len(seg.Distances)-1] - seg.Distances[0]) / 1000 + slopePercent := climb.Slope(seg.Distances, seg.Elevations) * 100 + pts := int(seg.DifficultyScore()) + return fmt.Sprintf("%.1fkm-%.1fkm: %.1fkm at %.1f%% (%d pts - %s)", startKm, endKm, lengthKm, slopePercent, pts, climb.CategoryOf(seg.DifficultyScore())) +} + +func TestPogacar20220721(t *testing.T) { + r, err := ride.ParseFile(parser, "../examples/2022-07-21.Pogacar.gpx") + assert.NoError(t, err) + detected := climb.Detect(r.Route, nil, climb.ClimbSlopeMinimum) + + assert.Len(t, detected, 6) + assert.Equal(t, "0.6km-5.6km: 5.0km at 3.5% (92 pts - Cat 3)", describeClimb(r.Route, detected[0])) + assert.Equal(t, "42.9km-45.2km: 2.3km at 5.0% (74 pts - Cat 4)", describeClimb(r.Route, detected[1])) + assert.Equal(t, "60.0km-76.4km: 16.4km at 7.2% (977 pts - HC)", describeClimb(r.Route, detected[2])) + assert.Equal(t, "83.8km-85.9km: 2.1km at 5.4% (79 pts - Cat 4)", describeClimb(r.Route, detected[3])) + assert.Equal(t, "99.2km-109.3km: 10.2km at 8.4% (751 pts - HC)", describeClimb(r.Route, detected[4])) + assert.Equal(t, "128.6km-142.2km: 13.6km at 7.8% (930 pts - HC)", describeClimb(r.Route, detected[5])) +} + +func TestBouclesVerdon2024(t *testing.T) { + r, err := ride.ParseFile(parser, "../examples/2024-06-29.BouclesVerdon.gpx") + assert.NoError(t, err) + detected := climb.Detect(r.Route, nil, climb.ClimbSlopeMinimum) + + assert.Len(t, detected, 6) + assert.Equal(t, "6.8km-9.1km: 2.3km at 4.0% (41 pts - Cat 4)", describeClimb(r.Route, detected[0])) + assert.Equal(t, "34.7km-37.3km: 2.5km at 5.0% (67 pts - Cat 4)", describeClimb(r.Route, detected[1])) + assert.Equal(t, "41.9km-43.8km: 1.8km at 4.6% (39 pts - Cat 4)", describeClimb(r.Route, detected[2])) + assert.Equal(t, "57.6km-60.8km: 3.1km at 6.3% (139 pts - Cat 3)", describeClimb(r.Route, detected[3])) + assert.Equal(t, "71.7km-74.7km: 3.0km at 5.3% (90 pts - Cat 3)", describeClimb(r.Route, detected[4])) + assert.Equal(t, "77.4km-78.7km: 1.3km at 5.3% (39 pts - Cat 4)", describeClimb(r.Route, detected[5])) +} + +func TestMimetArbois20241229(t *testing.T) { + r, err := ride.ParseFile(parser, "../examples/2024-12-29.MimetArbois.gpx") + assert.NoError(t, err) + detected := climb.Detect(r.Route, nil, climb.ClimbSlopeMinimum) + + assert.Len(t, detected, 2) + assert.Equal(t, "3.9km-5.3km: 1.4km at 10.3% (165 pts - Cat 3)", describeClimb(r.Route, detected[0])) + assert.Equal(t, "14.4km-19.8km: 5.4km at 4.3% (113 pts - Cat 3)", describeClimb(r.Route, detected[1])) +} + +func TestMimetArbois20250104(t *testing.T) { + r, err := ride.ParseFile(parser, "../examples/2025-01-04.MimetArbois.gpx") + assert.NoError(t, err) + detected := climb.Detect(r.Route, nil, climb.ClimbSlopeMinimum) + + assert.Len(t, detected, 2) + assert.Equal(t, "4.5km-5.9km: 1.4km at 10.1% (159 pts - Cat 3)", describeClimb(r.Route, detected[0])) + assert.Equal(t, "12.0km-15.1km: 3.2km at 4.5% (99 pts - Cat 3)", describeClimb(r.Route, detected[1])) +} + +// https://www.strava.com/activities/9282265844 +func TestAlpesVerdonTour20230617(t *testing.T) { + r, err := ride.ParseFile(parser, "../examples/2023-06-17.AlpesVerdonTour.gpx") + assert.NoError(t, err) + detected := climb.Detect(r.Route, nil, climb.ClimbSlopeMinimum) + + assert.Len(t, detected, 6) + assert.Equal(t, "0.7km-4.6km: 3.9km at 4.2% (72 pts - Cat 4)", describeClimb(r.Route, detected[0])) + assert.Equal(t, "8.2km-18.6km: 10.3km at 4.4% (292 pts - Cat 1)", describeClimb(r.Route, detected[1])) + assert.Equal(t, "36.1km-41.6km: 5.4km at 5.8% (193 pts - Cat 2)", describeClimb(r.Route, detected[2])) + assert.Equal(t, "47.0km-48.2km: 1.2km at 6.1% (45 pts - Cat 4)", describeClimb(r.Route, detected[3])) + assert.Equal(t, "51.9km-54.7km: 2.8km at 4.0% (48 pts - Cat 4)", describeClimb(r.Route, detected[4])) + assert.Equal(t, "114.1km-114.7km: 0.6km at 14.3% (119 pts - Cat 3)", describeClimb(r.Route, detected[5])) +} diff --git a/ride/difficulty_example_test.go b/ride/difficulty_example_test.go new file mode 100644 index 0000000..bd87f58 --- /dev/null +++ b/ride/difficulty_example_test.go @@ -0,0 +1,25 @@ +package ride_test + +import ( + "testing" + + "github.com/martinlehoux/biking_home/climb" + "github.com/martinlehoux/biking_home/ride" + "github.com/stretchr/testify/assert" +) + +func TestCotacolExampleRide(t *testing.T) { + r, err := ride.ParseFile(parser, "../examples/2022-07-21.Pogacar.gpx") + assert.NoError(t, err) + assert.InDelta(t, 3049.0, climb.Cotacol(r.Distances, r.Elevations), 1) +} + +func TestCotacolHautacamClimbfinderReference(t *testing.T) { + r, err := ride.ParseFile(parser, "../examples/2022-07-21.Pogacar.gpx") + assert.NoError(t, err) + detected := climb.Detect(r.Route, nil, climb.ClimbSlopeMinimum) + assert.Len(t, detected, 6) + hautacam := detected[5].Climb + assert.InDelta(t, 128.6, hautacam.Distances[0]/1000, 0.1) + assert.InDelta(t, 930.0, hautacam.DifficultyScore(), 10) +} diff --git a/ride/difficulty_test.go b/ride/difficulty_test.go deleted file mode 100644 index bd316b4..0000000 --- a/ride/difficulty_test.go +++ /dev/null @@ -1,87 +0,0 @@ -package ride_test - -import ( - "math" - "testing" - - "github.com/martinlehoux/biking_home/internal/ridetest" - "github.com/martinlehoux/biking_home/ride" - "github.com/stretchr/testify/assert" -) - -func TestCotacolConstantClimb(t *testing.T) { - r := ridetest.MustRide("2km at 7%") - assert.InDelta(t, 98.0, ride.Cotacol(r), 1e-9) -} - -func TestCotacolIgnoresDescent(t *testing.T) { - r := ridetest.MustRide("2km at 7% | 2km at -7%") - assert.InDelta(t, 98.0, ride.Cotacol(r), 1e-9) -} - -func TestCotacolFlatIsZero(t *testing.T) { - r := ridetest.MustRide("5km at 0%") - assert.Zero(t, ride.Cotacol(r)) -} - -func TestCotacolSteepShortCountsMore(t *testing.T) { - steep := ridetest.MustRide("0.5km at 14%") - long := ridetest.MustRide("1km at 7%") - assert.InDelta(t, 98.0, ride.Cotacol(steep), 1e-9) - assert.InDelta(t, 49.0, ride.Cotacol(long), 1e-9) -} - -func TestCotacolPrecisionInvariant(t *testing.T) { - coarse := ridetest.MustRide("3km at 5%") - fine := ridetest.MustRide("3km at 5%", ridetest.Sampling(50)) - assert.InDelta(t, ride.Cotacol(coarse), ride.Cotacol(fine), 1e-9) -} - -func TestCotacolExampleRide(t *testing.T) { - r, err := ride.ParseFile(parser, "../examples/2022-07-21.Pogacar.gpx") - assert.NoError(t, err) - assert.InDelta(t, 3049.0, ride.Cotacol(r), 1) -} - -func TestCotacolClimbNotAtStart(t *testing.T) { - r := ridetest.MustRide("1km at 0% | 2km at 7%") - climbs := r.DetectClimbs(nil, ride.ClimbSlopeMinimum) - assert.Len(t, climbs, 1) - assert.InDelta(t, 98.0, climbs[0].DifficultyScore(), 1e-9) -} - -func TestCategoryOfBoundaries(t *testing.T) { - cases := []struct { - score float64 - want ride.Category - }{ - {-5, ride.CategoryNo}, - {30, ride.CategoryNo}, - {35, ride.CategoryFour}, // 35 is the first floor, not below it - {79, ride.CategoryFour}, - {80, ride.CategoryThree}, - {179, ride.CategoryThree}, - {180, ride.CategoryTwo}, - {249, ride.CategoryTwo}, - {250, ride.CategoryOne}, - {599, ride.CategoryOne}, - {600, ride.CategoryHC}, - {700, ride.CategoryHC}, - {math.NaN(), ride.CategoryHC}, // reaches no floor, like the original switch default - } - for _, c := range cases { - assert.Equal(t, c.want, ride.CategoryOf(c.score), "score %v", c.score) - } - assert.Equal(t, "Cat 1", ride.CategoryOne.String()) - assert.Equal(t, "NO", ride.CategoryNo.String()) -} - -func TestCotacolHautacamClimbfinderReference(t *testing.T) { - r, err := ride.ParseFile(parser, "../examples/2022-07-21.Pogacar.gpx") - assert.NoError(t, err) - climbs := r.DetectClimbs(nil, ride.ClimbSlopeMinimum) - assert.Len(t, climbs, 6) - hautacam := climbs[5] - assert.InDelta(t, 128.6, hautacam.StartDistanceM()/1000, 0.1) - assert.InDelta(t, 930.0, hautacam.DifficultyScore(), 10) -} diff --git a/ride/ecs_test.go b/ride/ecs_test.go deleted file mode 100644 index 01c0628..0000000 --- a/ride/ecs_test.go +++ /dev/null @@ -1,43 +0,0 @@ -package ride - -import ( - "testing" - "time" - - "github.com/jftuga/geodist" - "github.com/stretchr/testify/assert" -) - -func TestRideExposesComponentGetters(t *testing.T) { - distances := []float64{0, 500, 1000} - elevations := []float64{0, 35, 70} - coords := []geodist.Coord{{Lat: 43.0, Lon: 5.0}, {Lat: 43.1, Lon: 5.1}, {Lat: 43.2, Lon: 5.2}} - timestamps := []time.Time{{}, time.Time{}.Add(time.Second), time.Time{}.Add(2 * time.Second)} - - r := FromColumns(distances, elevations, coords, timestamps) - - assert.Equal(t, distances, r.Distances()) - assert.Equal(t, elevations, r.Elevations()) - assert.Equal(t, coords, r.Coords()) - assert.Equal(t, timestamps, r.Timestamps()) - - powers, ok := r.Powers() - assert.False(t, ok, "no power component present") - assert.Nil(t, powers) - cadences, ok := r.Cadences() - assert.False(t, ok, "no cadence component present") - assert.Nil(t, cadences) -} - -func TestDifficultyScoreReadsOnlyDistanceAndElevationComponents(t *testing.T) { - // A 1 km climb at a steady 7%, sampled every 100 m. - distances := make([]float64, 0, 11) - elevations := make([]float64, 0, 11) - for i := 0; i <= 10; i++ { - distances = append(distances, float64(i*100)) - elevations = append(elevations, float64(i*7)) - } - - // Reference Cotacol value: 10 segments of 100 m at 7% => 49 pts. - assert.InDelta(t, 49, difficultyScore(distances, elevations, 0, len(distances)-1), 0.5) -} diff --git a/ride/moving_time.go b/ride/moving_time.go index 2ab07bf..62deb24 100644 --- a/ride/moving_time.go +++ b/ride/moving_time.go @@ -19,7 +19,7 @@ const movingTimeMaxGap = 120 * time.Second // Bench (104 rides, `-backtest`, distance >= 10 km, moving/elapsed ratio >= 0.8): // absMean 0.4%, stderr 0.1, p50 0.2, p75 0.4, p90 0.8, p95 1.4, p99 2.9. func MovingTime(r Ride) time.Duration { - return movingTime(r.timestamps, r.distances, r.powers, r.cadences, movingTimeMinSpeedMps, movingTimeMaxGap) + return movingTime(r.timestamps, r.Distances, r.powers, r.cadences, movingTimeMinSpeedMps, movingTimeMaxGap) } func movingTime(timestamps []time.Time, distances, powers, cadences []float64, minSpeedMps float64, maxGap time.Duration) time.Duration { diff --git a/ride/parser_test.go b/ride/parser_test.go index d8ac4d6..b9f0fb1 100644 --- a/ride/parser_test.go +++ b/ride/parser_test.go @@ -24,7 +24,7 @@ func TestGPXRideParserSkipsStationaryPoints(t *testing.T) { parsed, err := (ride.GPXRideParser{}).Parse(strings.NewReader(data)) require.NoError(t, err) assert.Equal(t, 2, parsed.Len()) - assert.Greater(t, parsed.Distances()[1], 0.0) + assert.Greater(t, parsed.Distances[1], 0.0) } func TestGPXRideParserRejectsStationaryRide(t *testing.T) { diff --git a/ride/ride.go b/ride/ride.go index 7863878..8e8bf95 100644 --- a/ride/ride.go +++ b/ride/ride.go @@ -4,13 +4,13 @@ import ( "errors" "fmt" "io" - "math" "os" "strconv" "strings" "time" "github.com/jftuga/geodist" + "github.com/martinlehoux/biking_home/climb" "github.com/martinlehoux/kagamigo/kcore" "github.com/tkrajina/gpxgo/gpx" ) @@ -20,9 +20,7 @@ const garminTrackPointExtensionNamespace = "http://www.garmin.com/xmlschemas/Tra var ErrNoTrackPoints = errors.New("ride has no track points") type Ride struct { - distances []float64 - elevations []float64 - coords []geodist.Coord + climb.Route timestamps []time.Time heartRates []float64 cadences []float64 @@ -31,26 +29,20 @@ type Ride struct { func (r *Ride) check() { kcore.Assert(r.Len() > 0, "no points in ride") - kcore.Assert(len(r.distances) == len(r.elevations), "ride columns have different lengths") - kcore.Assert(len(r.distances) == len(r.coords), "ride columns have different lengths") - kcore.Assert(len(r.distances) == len(r.timestamps), "ride columns have different lengths") + kcore.Assert(len(r.Distances) == len(r.Elevations), "ride columns have different lengths") + kcore.Assert(len(r.Distances) == len(r.Coords), "ride columns have different lengths") + kcore.Assert(len(r.Distances) == len(r.timestamps), "ride columns have different lengths") for _, column := range [][]float64{r.heartRates, r.cadences, r.powers} { if len(column) > 0 { - kcore.Assert(len(r.distances) == len(column), "ride columns have different lengths") + kcore.Assert(len(r.Distances) == len(column), "ride columns have different lengths") } } } func (r Ride) Len() int { - return len(r.distances) + return len(r.Distances) } -func (r Ride) Distances() []float64 { return r.distances } - -func (r Ride) Elevations() []float64 { return r.elevations } - -func (r Ride) Coords() []geodist.Coord { return r.coords } - func (r Ride) Timestamps() []time.Time { return r.timestamps } func (r Ride) Powers() ([]float64, bool) { return optionalComponent(r.powers) } @@ -170,9 +162,11 @@ func FromColumnsWithMetrics(distances []float64, elevations []float64, coords [] func fromColumns(distances []float64, elevations []float64, coords []geodist.Coord, timestamps []time.Time, heartRates, cadences, powers []float64) Ride { ride := Ride{ - distances: distances, - elevations: elevations, - coords: coords, + Route: climb.Route{ + Distances: distances, + Elevations: elevations, + Coords: coords, + }, timestamps: timestamps, heartRates: heartRates, cadences: cadences, @@ -229,81 +223,6 @@ func metricValue(point gpx.GPXPoint, name string) (*float64, error) { return &value, nil } -const CotacolAlgorithmVersion = "v1" - -// cotacolSegmentLengthM is the fixed length of the elevation segments the -// Cotacol score is computed over. -const cotacolSegmentLengthM = 100 - -func Cotacol(ride Ride) float64 { - return difficultyScore(ride.distances, ride.elevations, 0, ride.Len()-1) -} - -func difficultyScore(distances, elevations []float64, startIndex, endIndex int) float64 { - if endIndex-startIndex < 1 { - return 0 - } - startDistance := distances[startIndex] - lastDistance := distances[endIndex] - if lastDistance <= startDistance { - return 0 - } - cotacol := 0.0 - i := startIndex - for start := startDistance; start < lastDistance; start += cotacolSegmentLengthM { - end := math.Min(start+cotacolSegmentLengthM, lastDistance) - for i < endIndex && distances[i+1] <= start { - i++ - } - startElevation := interpolateElevation(distances, elevations, i, start) - for i < endIndex && distances[i+1] < end { - i++ - } - endElevation := interpolateElevation(distances, elevations, i, end) - slope := (endElevation - startElevation) / (end - start) - if slope > 0 { - cotacol += (end - start) / 1000 * (slope * 100) * (slope * 100) - } - } - return cotacol -} - -func interpolateElevation(distances, elevations []float64, i int, distance float64) float64 { - if i+1 >= len(distances) { - return elevations[i] - } - startDistance := distances[i] - endDistance := distances[i+1] - t := (distance - startDistance) / (endDistance - startDistance) - return elevations[i] + t*(elevations[i+1]-elevations[i]) -} - -func (r *Ride) ClimbFromDist(startDist, endDist float64) Climb { - start, end := 0, 0 - for i, distance := range r.distances { - if start == 0 && distance >= startDist { - start = i - } - if end == 0 && distance >= endDist { - end = i - break - } - } - return r.climbFrom(start, end) -} - -func (r *Ride) ClimbFromIndexes(startIndex, endIndex int) Climb { - kcore.Assert(startIndex >= 0 && endIndex < r.Len() && startIndex < endIndex, "invalid climb indexes") - return r.climbFrom(startIndex, endIndex) -} - -func (r *Ride) climbFrom(start, end int) Climb { - return Climb{ - distances: r.distances, - elevations: r.elevations, - coords: r.coords, - timestamps: r.timestamps, - rideStart: start, - rideEnd: end, - } +func (ride Ride) Cotacol() float64 { + return climb.Cotacol(ride.Distances, ride.Elevations) } diff --git a/ride/store.go b/ride/store.go index 2d022ad..b906b42 100644 --- a/ride/store.go +++ b/ride/store.go @@ -7,6 +7,7 @@ import ( "strings" "time" + "github.com/martinlehoux/biking_home/climb" "github.com/martinlehoux/kagamigo/kcore" ) @@ -57,8 +58,8 @@ func Save(db *sql.DB, r StoredRide) error { if err != nil { return fmt.Errorf("compute ride values for %q: %w", r.ExternalID, err) } - cotacolScore = Cotacol(parsed) - cotacolAlgorithmVersion = CotacolAlgorithmVersion + cotacolScore = climb.Cotacol(parsed.Distances, parsed.Elevations) + cotacolAlgorithmVersion = climb.CotacolAlgorithmVersion movingTimeS = int64(MovingTime(parsed) / time.Second) movingTimeAlgorithmVersion = MovingTimeAlgorithmVersion } @@ -151,7 +152,7 @@ func ListSorted(db *sql.DB, column SortColumn, descending bool) ([]StoredRide, e args = append(args, MovingTimeAlgorithmVersion) } else if isComputedSort(column) { query += "(cotacol_score IS NULL OR cotacol_algo_version IS NULL OR cotacol_algo_version <> ? OR distance_m <= 0) ASC, " - args = append(args, CotacolAlgorithmVersion) + args = append(args, climb.CotacolAlgorithmVersion) } query += expression + " " + direction + ", id DESC" rows, err := db.Query(query, args...) diff --git a/ride/store_test.go b/ride/store_test.go index 89c4830..1ee3c69 100644 --- a/ride/store_test.go +++ b/ride/store_test.go @@ -7,6 +7,7 @@ import ( "testing" "time" + "github.com/martinlehoux/biking_home/climb" "github.com/martinlehoux/biking_home/internal/dbtest" "github.com/martinlehoux/kagamigo/kcore" "github.com/stretchr/testify/assert" @@ -53,7 +54,7 @@ func TestUpsertAndGet(t *testing.T) { score, found := got.CotacolScore() require.True(t, found) assert.Greater(t, score, 0.0) - assert.Equal(t, CotacolAlgorithmVersion, got.CotacolAlgorithmVersion()) + assert.Equal(t, climb.CotacolAlgorithmVersion, got.CotacolAlgorithmVersion()) _, ok, err = GetByExternalID(db, "strava:9999") require.NoError(t, err) @@ -89,7 +90,7 @@ func TestSaveComputesCotacol(t *testing.T) { score, found := got.CotacolScore() require.True(t, found) assert.Greater(t, score, 0.0) - assert.Equal(t, CotacolAlgorithmVersion, got.CotacolAlgorithmVersion()) + assert.Equal(t, climb.CotacolAlgorithmVersion, got.CotacolAlgorithmVersion()) } func TestSaveComputesMovingTimeAndPreservesExternal(t *testing.T) { @@ -190,7 +191,7 @@ func TestBackfill(t *testing.T) { require.True(t, ok) _, found := got.CotacolScore() assert.True(t, found) - assert.Equal(t, CotacolAlgorithmVersion, got.CotacolAlgorithmVersion()) + assert.Equal(t, climb.CotacolAlgorithmVersion, got.CotacolAlgorithmVersion()) count, err = Backfill(db) require.NoError(t, err) @@ -277,7 +278,7 @@ func TestListSortedByCotacolKeepsStaleLast(t *testing.T) { UPDATE rides SET cotacol_score = CASE external_id WHEN ? THEN 4 ELSE 6 END, cotacol_algo_version = ? - `, first.ExternalID, CotacolAlgorithmVersion) + `, first.ExternalID, climb.CotacolAlgorithmVersion) require.NoError(t, err) items, err := ListSorted(db, SortCotacol, false) @@ -307,7 +308,7 @@ func TestListSortedByCotacolPer100Km(t *testing.T) { UPDATE rides SET cotacol_score = CASE external_id WHEN ? THEN 4 ELSE 6 END, cotacol_algo_version = ? - `, first.ExternalID, CotacolAlgorithmVersion) + `, first.ExternalID, climb.CotacolAlgorithmVersion) require.NoError(t, err) items, err := ListSorted(db, SortCotacolKm, false) diff --git a/rideanalysis/analysis.go b/rideanalysis/analysis.go index 5e08e65..a9cf93d 100644 --- a/rideanalysis/analysis.go +++ b/rideanalysis/analysis.go @@ -3,6 +3,8 @@ package rideanalysis import ( "slices" + "github.com/martinlehoux/biking_home/climb" + "github.com/martinlehoux/biking_home/core" "github.com/martinlehoux/biking_home/mountain_pass" "github.com/martinlehoux/biking_home/official_climb" "github.com/martinlehoux/biking_home/ride" @@ -21,7 +23,7 @@ type Result struct { } type AnalyzedClimb struct { - Segment ride.Climb + Segment climb.Climb Name string Category string DistanceM float64 @@ -29,6 +31,7 @@ type AnalyzedClimb struct { Cotacol float64 OfficialClimbID int64 OfficialName string + Interval core.Interval } func Analyze(parsed ride.Ride, passes []mountain_pass.MountainPass, officialClimbs []official_climb.OfficialClimb, matchPolicy official_climb.MatchPolicy) Result { @@ -37,45 +40,57 @@ func Analyze(parsed ride.Ride, passes []mountain_pass.MountainPass, officialClim Crossings: make([]mountain_pass.Crossing, 0), } claimedClimbs := make([]AnalyzedClimb, 0) - claimedRanges := make([]ride.Range, 0) + claimedRanges := make([]core.Interval, 0) for _, official := range officialClimbs { - for _, matched := range official_climb.MatchClimbs(parsed.Coords(), official, matchPolicy.EndpointRadiusM) { - claimedRanges = append(claimedRanges, ride.Range{Start: matched.StartIndex, End: matched.EndIndex}) - claimedClimbs = append(claimedClimbs, analyzedClimb(parsed, parsed.ClimbFromIndexes(matched.StartIndex, matched.EndIndex), official.Name, official.ID)) + for _, matched := range official_climb.MatchClimbs(parsed.Coords, official, matchPolicy.EndpointRadiusM) { + interval := core.NewInterval(matched.StartIndex, matched.EndIndex) + claimedRanges = append(claimedRanges, interval) + claimedClimbs = append(claimedClimbs, analyzedClimb(routeClimb(parsed.Route, interval), interval, official.Name, official.ID)) } } - slices.SortFunc(claimedRanges, func(a, b ride.Range) int { return a.Start - b.Start }) - for _, detectedClimb := range parsed.DetectClimbs(claimedRanges, ride.ClimbSlopeMinimum) { - displayClimb := detectedClimb - if matchedPass, ok := mountain_pass.MatchClimb(displayClimb, passes, passMatchRadiusM, passMatchElevationToleranceM); ok { - displayClimb.Name = matchedPass.Name + slices.SortFunc(claimedRanges, func(a, b core.Interval) int { return a.Start - b.Start }) + for _, detected := range climb.Detect(parsed.Route, claimedRanges, climb.ClimbSlopeMinimum) { + name := "" + if matchedPass, ok := mountain_pass.MatchClimb(detected.Climb, passes, passMatchRadiusM, passMatchElevationToleranceM); ok { + name = matchedPass.Name } - result.Climbs = append(result.Climbs, analyzedClimb(parsed, displayClimb, displayClimb.Name, 0)) + interval := core.NewInterval(detected.Start, detected.Start+len(detected.Climb.Distances)-1) + result.Climbs = append(result.Climbs, analyzedClimb(detected.Climb, interval, name, 0)) } climbs := make([]AnalyzedClimb, 0, len(claimedClimbs)+len(result.Climbs)) climbs = append(climbs, claimedClimbs...) climbs = append(climbs, result.Climbs...) slices.SortFunc(climbs, func(a, b AnalyzedClimb) int { - return a.Segment.StartIndex() - b.Segment.StartIndex() + return a.Interval.Start - b.Interval.Start }) result.Climbs = climbs - for _, crossing := range mountain_pass.DetectCrossings(parsed.Coords(), parsed.Distances(), parsed.Elevations(), passes, passCrossingRadiusM, passCrossingElevationToleranceM) { + for _, crossing := range mountain_pass.DetectCrossings(parsed.Coords, parsed.Distances, parsed.Elevations, passes, passCrossingRadiusM, passCrossingElevationToleranceM) { result.Crossings = append(result.Crossings, crossing) } return result } -func analyzedClimb(parsed ride.Ride, segment ride.Climb, name string, officialID int64) AnalyzedClimb { +// routeClimb returns the climb segment of route covered by interval. +func routeClimb(route climb.Route, interval core.Interval) climb.Climb { + return climb.Climb{Route: climb.Route{ + Distances: route.Distances[interval.Start : interval.End+1], + Elevations: route.Elevations[interval.Start : interval.End+1], + Coords: route.Coords[interval.Start : interval.End+1], + }} +} + +func analyzedClimb(segment climb.Climb, interval core.Interval, name string, officialID int64) AnalyzedClimb { cotacol := segment.DifficultyScore() return AnalyzedClimb{ Segment: segment, Name: name, - Category: ride.CategoryOf(cotacol).String(), - DistanceM: segment.EndDistanceM() - segment.StartDistanceM(), - SlopePercent: ride.Slope(parsed.Distances(), parsed.Elevations(), segment.StartIndex(), segment.EndIndex()) * 100, + Category: climb.CategoryOf(cotacol).String(), + DistanceM: segment.Distances[len(segment.Distances)-1] - segment.Distances[0], + SlopePercent: climb.Slope(segment.Distances, segment.Elevations) * 100, Cotacol: cotacol, OfficialClimbID: officialID, OfficialName: officialClimbNameForID(name, officialID), + Interval: interval, } } diff --git a/web/ride-filters.go b/web/ride-filters.go index 0bb97d8..8535756 100644 --- a/web/ride-filters.go +++ b/web/ride-filters.go @@ -6,6 +6,7 @@ import ( "strconv" "time" + "github.com/martinlehoux/biking_home/climb" "github.com/martinlehoux/biking_home/ride" ) @@ -417,7 +418,7 @@ func rideMatchesFilters(item ride.StoredRide, filters RideFilters) bool { } if filters.CotacolMinPoints != nil || filters.CotacolMaxPoints != nil { cotacol, ready := item.CotacolScore() - if !ready || item.CotacolAlgorithmVersion() != ride.CotacolAlgorithmVersion || item.DistanceM <= 0 { + if !ready || item.CotacolAlgorithmVersion() != climb.CotacolAlgorithmVersion || item.DistanceM <= 0 { return false } if filters.CotacolMinPoints != nil && cotacol < float64(*filters.CotacolMinPoints) { diff --git a/web/ride-filters_test.go b/web/ride-filters_test.go index e4efc02..84c6417 100644 --- a/web/ride-filters_test.go +++ b/web/ride-filters_test.go @@ -8,6 +8,7 @@ import ( "testing" "time" + "github.com/martinlehoux/biking_home/climb" "github.com/martinlehoux/biking_home/ride" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -209,7 +210,7 @@ func TestFilterRidesByCotacolExcludesUnavailableScores(t *testing.T) { WHEN ? THEN 'old' END WHERE external_id IN (?, ?) - `, current.ExternalID, stale.ExternalID, current.ExternalID, ride.CotacolAlgorithmVersion, stale.ExternalID, current.ExternalID, stale.ExternalID) + `, current.ExternalID, stale.ExternalID, current.ExternalID, climb.CotacolAlgorithmVersion, stale.ExternalID, current.ExternalID, stale.ExternalID) require.NoError(t, err) items, err := ride.List(db) require.NoError(t, err) diff --git a/web/rides.go b/web/rides.go index 71ab3fd..d3f5634 100644 --- a/web/rides.go +++ b/web/rides.go @@ -6,6 +6,7 @@ import ( "net/http" "strconv" + "github.com/martinlehoux/biking_home/climb" "github.com/martinlehoux/biking_home/mountain_pass" "github.com/martinlehoux/biking_home/official_climb" "github.com/martinlehoux/biking_home/ride" @@ -65,7 +66,7 @@ func (s *Server) handleRide(w http.ResponseWriter, r *http.Request) { }), w) return } - passes, err := mountain_pass.LoadMountainPassesAroundRide(s.db, parsed.Coords(), rideDetailMountainPassMarginM) + passes, err := mountain_pass.LoadMountainPassesAroundRide(s.db, parsed.Coords, rideDetailMountainPassMarginM) if err != nil { slog.Warn("Failed to load nearby mountain passes for ride detail", "ride_id", id, "error", err) } @@ -144,7 +145,7 @@ func (s *Server) handleCreateOfficialClimb(w http.ResponseWriter, r *http.Reques http.Error(w, "the recorded route is unavailable", http.StatusBadRequest) return } - bounds := parsed.Coords() + bounds := parsed.Coords if startIndex >= endIndex || endIndex >= len(bounds) { http.Error(w, "the official climb boundaries must be ordered route points", http.StatusBadRequest) return @@ -192,7 +193,7 @@ func buildRideViews(items []ride.StoredRide) []RideView { func buildRideView(item ride.StoredRide) RideView { view := RideView{Ride: item, Cotacol: "-", CotacolPer100Km: "-", MovingTime: formatDuration(item.EffectiveMovingTimeS())} cotacol, ready := item.CotacolScore() - if ready && item.CotacolAlgorithmVersion() == ride.CotacolAlgorithmVersion && item.DistanceM > 0 { + if ready && item.CotacolAlgorithmVersion() == climb.CotacolAlgorithmVersion && item.DistanceM > 0 { view.Cotacol = formatCotacol(cotacol) view.CotacolPer100Km = formatCotacolPer100Km(cotacol, item.DistanceM) } diff --git a/web/server_test.go b/web/server_test.go index 6a1c13f..a60d742 100644 --- a/web/server_test.go +++ b/web/server_test.go @@ -15,6 +15,7 @@ import ( "time" "github.com/jftuga/geodist" + "github.com/martinlehoux/biking_home/climb" "github.com/martinlehoux/biking_home/config" "github.com/martinlehoux/biking_home/internal/dbtest" "github.com/martinlehoux/biking_home/internal/ridetest" @@ -225,8 +226,8 @@ func TestBuildRideProfileIncludesOfficialClimbMatch(t *testing.T) { profile := buildRideProfile(parsed, nil, []official_climb.OfficialClimb{{ ID: 42, Name: "Col de Test", - StartCoord: parsed.Coords()[0], - EndCoord: parsed.Coords()[1], + StartCoord: parsed.Coords[0], + EndCoord: parsed.Coords[1], }}, official_climb.DefaultMatchPolicy()) require.Len(t, profile.Climbs, 1) @@ -242,8 +243,8 @@ func TestBuildRideProfileUsesOfficialClimbBoundaries(t *testing.T) { profile := buildRideProfile(parsed, nil, []official_climb.OfficialClimb{{ ID: 42, Name: "Col de Test", - StartCoord: parsed.Coords()[0], - EndCoord: parsed.Coords()[2], + StartCoord: parsed.Coords[0], + EndCoord: parsed.Coords[2], }}, official_climb.DefaultMatchPolicy()) require.Len(t, profile.Climbs, 1) @@ -370,7 +371,7 @@ func TestHandlerRendersAnalyticsChart(t *testing.T) { SET cotacol_score = CASE external_id WHEN ? THEN 20 ELSE 120 END, cotacol_algo_version = ?, average_speed_mps = CASE external_id WHEN ? THEN 7.0 ELSE 4.0 END - `, first.ExternalID, ride.CotacolAlgorithmVersion, second.ExternalID) + `, first.ExternalID, climb.CotacolAlgorithmVersion, second.ExternalID) require.NoError(t, err) req := httptest.NewRequest(http.MethodGet, "/analytics", nil) @@ -416,7 +417,7 @@ func TestHandlerFiltersAnalyticsByDistance(t *testing.T) { SET cotacol_score = CASE external_id WHEN ? THEN 20 ELSE 120 END, cotacol_algo_version = ?, average_speed_mps = CASE external_id WHEN ? THEN 7.0 ELSE 5.0 END - `, short.ExternalID, ride.CotacolAlgorithmVersion, long.ExternalID) + `, short.ExternalID, climb.CotacolAlgorithmVersion, long.ExternalID) require.NoError(t, err) req := httptest.NewRequest(http.MethodGet, "/analytics?distance_min=60", nil) diff --git a/web/views.go b/web/views.go index b3cab8b..4f769cc 100644 --- a/web/views.go +++ b/web/views.go @@ -5,6 +5,7 @@ import ( "net/url" "time" + "github.com/martinlehoux/biking_home/climb" "github.com/martinlehoux/biking_home/mountain_pass" "github.com/martinlehoux/biking_home/official_climb" "github.com/martinlehoux/biking_home/ride" @@ -47,7 +48,7 @@ func buildAnalyticsPoints(items []ride.StoredRide) []AnalyticsPoint { points := make([]AnalyticsPoint, 0, len(items)) for _, item := range items { cotacol, ready := item.CotacolScore() - if !ready || item.CotacolAlgorithmVersion() != ride.CotacolAlgorithmVersion || item.DistanceM <= 0 { + if !ready || item.CotacolAlgorithmVersion() != climb.CotacolAlgorithmVersion || item.DistanceM <= 0 { continue } points = append(points, AnalyticsPoint{ @@ -286,7 +287,7 @@ func officialClimbCreateURL(id int64) string { } func buildRideDetailView(item ride.StoredRide, parsed ride.Ride, passes []mountain_pass.MountainPass, officialClimbs []official_climb.OfficialClimb, matchPolicy official_climb.MatchPolicy) RideDetailView { - coords := parsed.Coords() + coords := parsed.Coords coordinates := make([][]float64, len(coords)) for i, coordinate := range coords { coordinates[i] = []float64{coordinate.Lon, coordinate.Lat} @@ -327,9 +328,9 @@ func buildRideMapPasses(passes []mountain_pass.MountainPass) []RideMapPass { } func buildRideProfile(parsed ride.Ride, passes []mountain_pass.MountainPass, officialClimbs []official_climb.OfficialClimb, matchPolicy official_climb.MatchPolicy) RideProfile { - coords := parsed.Coords() - distances := parsed.Distances() - elevations := parsed.Elevations() + coords := parsed.Coords + distances := parsed.Distances + elevations := parsed.Elevations profile := RideProfile{ Points: make([]RideProfilePoint, len(coords)), Climbs: make([]RideProfileClimb, 0), @@ -346,12 +347,13 @@ func buildRideProfile(parsed ride.Ride, passes []mountain_pass.MountainPass, off analysis := rideanalysis.Analyze(parsed, passes, officialClimbs, matchPolicy) for _, analyzedClimb := range analysis.Climbs { segment := analyzedClimb.Segment + topIndex, topElevationM := segment.Top() climb := RideProfileClimb{ Index: len(profile.Climbs), - StartKm: segment.StartDistanceM() / 1000, - EndKm: segment.EndDistanceM() / 1000, - TopKm: segment.TopDistanceM() / 1000, - TopElevationM: segment.TopElevationM(), + StartKm: segment.Distances[0] / 1000, + EndKm: segment.Distances[len(segment.Distances)-1] / 1000, + TopKm: segment.Distances[topIndex] / 1000, + TopElevationM: topElevationM, Name: analyzedClimb.Name, Category: analyzedClimb.Category, DistanceKm: analyzedClimb.DistanceM / 1000, @@ -359,8 +361,8 @@ func buildRideProfile(parsed ride.Ride, passes []mountain_pass.MountainPass, off Cotacol: analyzedClimb.Cotacol, OfficialClimbID: analyzedClimb.OfficialClimbID, OfficialName: analyzedClimb.OfficialName, - StartIndex: segment.StartIndex(), - EndIndex: segment.EndIndex(), + StartIndex: analyzedClimb.Interval.Start, + EndIndex: analyzedClimb.Interval.End, } profile.Climbs = append(profile.Climbs, climb) } -- cgit v1.3