diff options
| author | Martin Kagamino Lehoux <martin@lehoux.net> | 2026-08-21 15:30:51 +0200 |
|---|---|---|
| committer | Martin Kagamino Lehoux <martin@lehoux.net> | 2026-08-21 15:30:51 +0200 |
| commit | 5e3d39cda5250cb55bc8324bcf96b0ab4b36c47f (patch) | |
| tree | f92fe6f938866688b994c99670e1e27863579255 /ride | |
| parent | 0e27097e3d706e0e5bd761bf35fe301fd119d2a9 (diff) | |
feat: bound climb detection by matched official climbs
Detected climbs no longer absorb an official climb's range: official climbs
matched to the route by coordinates claim their stored start/end range, and
climb detection now runs only in the free regions around them. On ride 263 a
single absorbed Cat 2 ascent is split so creating a first-half official climb
lets the second half be detected on its own.
- ride: add Range, freeRegions, and DetectClimbs(claimed); exclude claimed
ranges so a climb never overlaps one
- official_climb: MatchClimb now bounds-matches purely by coordinates without
needing a detected climb; extract geo.Nearest shared helper
- rideanalysis: Analyze builds claimed climbs from matched official climbs and
detects only in free regions; render official vs unmatched climbs separately
Diffstat (limited to 'ride')
| -rw-r--r-- | ride/climb.go | 43 | ||||
| -rw-r--r-- | ride/climb_test.go | 41 | ||||
| -rw-r--r-- | ride/difficulty_test.go | 4 | ||||
| -rw-r--r-- | ride/ride.go | 8 |
4 files changed, 81 insertions, 15 deletions
diff --git a/ride/climb.go b/ride/climb.go index 29a4dcf..88b9d3b 100644 --- a/ride/climb.go +++ b/ride/climb.go @@ -221,8 +221,47 @@ func climbsBetween(r Ride, start, end int) []Climb { return climbs } -func (ride *Ride) AllClimbs() []Climb { - climbs := climbsBetween(*ride, 0, ride.Len()-1) +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 { + regions := make([]Range, 0, len(claimed)+1) + current := rStart + for _, claim := range claimed { + if !isValidClaim(claim, rEnd+1) { + continue + } + 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) []Climb { + climbs := make([]Climb, 0) + for _, region := range freeRegions(0, ride.Len()-1, claimed) { + climbs = append(climbs, climbsBetween(*ride, region.Start, region.End)...) + } slices.SortFunc(climbs, climbCmpStart) return climbs } diff --git a/ride/climb_test.go b/ride/climb_test.go index feca001..a5e332d 100644 --- a/ride/climb_test.go +++ b/ride/climb_test.go @@ -10,6 +10,7 @@ import ( "github.com/martinlehoux/biking_home/ride" "github.com/martinlehoux/kagamigo/kcore" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) type RideBuilderSection struct { @@ -66,7 +67,7 @@ var parser = ride.GPXRideParser{} func TestClimbThenFalseFlat(t *testing.T) { r := RideBuilder{precision: 100}.WithSection("2km at 7%").WithSection("10km at 1%").Build() - climbs := r.AllClimbs() + climbs := r.DetectClimbs(nil) assert.Len(t, climbs, 1) assert.Equal(t, "0.0km-2.0km: 2.0km at 7.0% (98 pts - Cat 3)", climbs[0].String()) @@ -74,7 +75,7 @@ func TestClimbThenFalseFlat(t *testing.T) { func TestSmallClimbWithDescentInsideFalseFlat(t *testing.T) { r := RideBuilder{precision: 100}.WithSection("10km at 1%").WithSection("2km at 7%").WithSection("1km at -7%").WithSection("10km at 1%").Build() - climbs := r.AllClimbs() + climbs := r.DetectClimbs(nil) assert.Len(t, climbs, 1) assert.Equal(t, "10.0km-12.0km: 2.0km at 7.0% (98 pts - Cat 3)", climbs[0].String()) @@ -83,7 +84,7 @@ func TestSmallClimbWithDescentInsideFalseFlat(t *testing.T) { func TestPogacar20220721(t *testing.T) { r, err := ride.ParseFile(parser, "../examples/2022-07-21.Pogacar.gpx") assert.NoError(t, err) - climbs := r.AllClimbs() + climbs := r.DetectClimbs(nil) assert.Len(t, climbs, 6) assert.Equal(t, "0.6km-5.6km: 5.0km at 3.5% (92 pts - Cat 3)", climbs[0].String()) @@ -97,7 +98,7 @@ func TestPogacar20220721(t *testing.T) { func TestBouclesVerdon2024(t *testing.T) { r, err := ride.ParseFile(parser, "../examples/2024-06-29.BouclesVerdon.gpx") assert.NoError(t, err) - climbs := r.AllClimbs() + climbs := r.DetectClimbs(nil) assert.Len(t, climbs, 6) assert.Equal(t, "3.6km-13.8km: 10.3km at 2.1% (98 pts - Cat 3)", climbs[0].String()) // TODO: Not steep enough @@ -111,7 +112,7 @@ func TestBouclesVerdon2024(t *testing.T) { func TestMimetArbois20241229(t *testing.T) { r, err := ride.ParseFile(parser, "../examples/2024-12-29.MimetArbois.gpx") assert.NoError(t, err) - climbs := r.AllClimbs() + climbs := r.DetectClimbs(nil) assert.Len(t, climbs, 2) assert.Equal(t, "3.9km-5.3km: 1.4km at 10.3% (165 pts - Cat 3)", climbs[0].String()) @@ -121,7 +122,7 @@ func TestMimetArbois20241229(t *testing.T) { func TestMimetArbois20250104(t *testing.T) { r, err := ride.ParseFile(parser, "../examples/2025-01-04.MimetArbois.gpx") assert.NoError(t, err) - climbs := r.AllClimbs() + climbs := r.DetectClimbs(nil) assert.Len(t, climbs, 2) assert.Equal(t, "4.5km-5.9km: 1.4km at 10.1% (159 pts - Cat 3)", climbs[0].String()) @@ -132,7 +133,7 @@ func TestMimetArbois20250104(t *testing.T) { func TestAlpesVerdonTour20230617(t *testing.T) { r, err := ride.ParseFile(parser, "../examples/2023-06-17.AlpesVerdonTour.gpx") assert.NoError(t, err) - climbs := r.AllClimbs() + climbs := r.DetectClimbs(nil) assert.Len(t, climbs, 6) assert.Equal(t, "0.7km-18.6km: 17.9km at 3.5% (364 pts - Cat 1)", climbs[0].String()) // TODO: Should split in 2 (10km at 4.5% is Cat 2) @@ -142,3 +143,29 @@ func TestAlpesVerdonTour20230617(t *testing.T) { assert.Equal(t, "86.7km-92.8km: 6.1km at 2.7% (60 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 := RideBuilder{precision: 100}.WithSection("2km at 7%").WithSection("1km at -7%").WithSection("2km at 7%").Build() + climbs := r.DetectClimbs([]ride.Range{{Start: 20, End: 20}}) + + // 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 := RideBuilder{precision: 100}.WithSection("2km at 7%").WithSection("20km at 0%").Build() + climbs := r.DetectClimbs([]ride.Range{{Start: 0, End: 40}}) + assert.Empty(t, climbs) +} + +func TestDetectClimbsIgnoresOutOfRangeClaim(t *testing.T) { + r := RideBuilder{precision: 100}.WithSection("2km at 7%").WithSection("20km at 0%").Build() + climbs := r.DetectClimbs([]ride.Range{{Start: -5, End: -1}}) + require.Len(t, climbs, 1) + assert.Equal(t, "0.0km-2.0km: 2.0km at 7.0% (98 pts - Cat 3)", climbs[0].String()) +} diff --git a/ride/difficulty_test.go b/ride/difficulty_test.go index 5773376..05b60f9 100644 --- a/ride/difficulty_test.go +++ b/ride/difficulty_test.go @@ -44,7 +44,7 @@ func TestCotacolExampleRide(t *testing.T) { func TestCotacolClimbNotAtStart(t *testing.T) { r := RideBuilder{precision: 100}.WithSection("1km at 0%").WithSection("2km at 7%").Build() - climbs := r.AllClimbs() + climbs := r.DetectClimbs(nil) assert.Len(t, climbs, 1) assert.InDelta(t, 98.0, climbs[0].DifficultyScore(), 1e-9) } @@ -78,7 +78,7 @@ func TestCategoryOfBoundaries(t *testing.T) { func TestCotacolHautacamClimbfinderReference(t *testing.T) { r, err := ride.ParseFile(parser, "../examples/2022-07-21.Pogacar.gpx") assert.NoError(t, err) - climbs := r.AllClimbs() + climbs := r.DetectClimbs(nil) assert.Len(t, climbs, 6) hautacam := climbs[5] assert.InDelta(t, 128.6, hautacam.StartDistanceM()/1000, 0.1) diff --git a/ride/ride.go b/ride/ride.go index a10d8a2..d5533ae 100644 --- a/ride/ride.go +++ b/ride/ride.go @@ -22,7 +22,7 @@ var ErrNoTrackPoints = errors.New("ride has no track points") type Ride struct { distances []float64 elevations []float64 - coords []geodist.Coord + Coords []geodist.Coord timestamps []time.Time heartRates []float64 cadences []float64 @@ -32,7 +32,7 @@ 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.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 { @@ -49,7 +49,7 @@ func (r Ride) DistanceM(i int) float64 { return r.distances[i] } func (r Ride) ElevationM(i int) float64 { return r.elevations[i] } -func (r Ride) Coord(i int) geodist.Coord { return r.coords[i] } +func (r Ride) Coord(i int) geodist.Coord { return r.Coords[i] } func (r Ride) Timestamp(i int) time.Time { return r.timestamps[i] } @@ -171,7 +171,7 @@ func fromColumns(distances []float64, elevations []float64, coords []geodist.Coo ride := Ride{ distances: distances, elevations: elevations, - coords: coords, + Coords: coords, timestamps: timestamps, heartRates: heartRates, cadences: cadences, |
