aboutsummaryrefslogtreecommitdiff
path: root/ride
diff options
context:
space:
mode:
authorMartin Kagamino Lehoux <martin@lehoux.net>2026-08-21 15:30:51 +0200
committerMartin Kagamino Lehoux <martin@lehoux.net>2026-08-21 15:30:51 +0200
commit5e3d39cda5250cb55bc8324bcf96b0ab4b36c47f (patch)
treef92fe6f938866688b994c99670e1e27863579255 /ride
parent0e27097e3d706e0e5bd761bf35fe301fd119d2a9 (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.go43
-rw-r--r--ride/climb_test.go41
-rw-r--r--ride/difficulty_test.go4
-rw-r--r--ride/ride.go8
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,