aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorMartin Kagamino Lehoux <martin@lehoux.net>2026-08-23 12:12:59 +0200
committerMartin Kagamino Lehoux <martin@lehoux.net>2026-08-23 12:12:59 +0200
commite5e7870eafbfc80f4668d2c0d0995149ddc2f60a (patch)
tree48d93921782aa41d5bc6fc699e1d3d01ec384131
parentd0875a51b2f07766b339b34a580d4e6e9fab348d (diff)
Match official climbs once per lap on looping rides
Official climb matching now returns all non-overlapping occurrences per ride instead of only the globally nearest pair. Each lap is detected by clustering contiguous points within the endpoint radius and pairing each start cluster with the first end cluster inside its window, preserving direction. The ride analysis aggregates all matches and sorts claimed ranges before excluding them from auto-detection. Fixes the case where a looping ride only showed one instance of a saved climb while the user expected one per repetition.
-rw-r--r--README.md3
-rw-r--r--official_climb/matcher.go84
-rw-r--r--official_climb/official_climb_test.go111
-rw-r--r--rideanalysis/analysis.go9
4 files changed, 193 insertions, 14 deletions
diff --git a/README.md b/README.md
index be72c1b..f7633cc 100644
--- a/README.md
+++ b/README.md
@@ -12,6 +12,7 @@ A Go toolkit for analyzing cycling rides from GPX exports: parse rides, detect c
- **Pass crossing detection** — flags which passes a ride crosses and names each climb after the pass it tops (e.g. "Col de Castellaras")
- **Interactive ride profiles** — explores elevation, climbs, and mountain-pass crossings in the web ride detail view
- **Official climb cards** — separates matched climbs from pending matches and expands each matched climb with its own elevation profile
+- **Repeated official climbs** — matches the same official climb once per lap on looping rides, respecting direction
- **Web ride library** — starts a local web server by default, imports Strava rides over a date range, stores metadata in SQLite, and keeps their GPX files on disk
- **Materialized ride values** — computes Cotacol on import, stores its algorithm version in SQLite, and refreshes all computed values with `-backfill`
- **Strava stream metrics** — preserves heart rate, cadence, and power in Garmin-compatible GPX files and in-memory ride columns
@@ -107,7 +108,6 @@ TypeScript checks can also be run directly with `npm run check`, covering format
## TODO
-- http://localhost:8080/rides/368: does not contain Pont Rout
- http://localhost:8080/rides/6: Cat 4 inside Chemin des Lauriers (embrassé)
- Compute estimated power, speed
- Categorised rides like to-dos
@@ -127,6 +127,7 @@ TypeScript checks can also be run directly with `npm run check`, covering format
- performance/profile tracking
- progress bar cli
- processing takes components as input, not entities
+- issue: many passes are pedestriant (cf Marseille Calanques, http://localhost:8080/rides/397)
## Resources
diff --git a/official_climb/matcher.go b/official_climb/matcher.go
index faf51a7..00f7e9b 100644
--- a/official_climb/matcher.go
+++ b/official_climb/matcher.go
@@ -20,16 +20,84 @@ type MatchedClimb struct {
// coordinates: both the start and end coordinates must be within radiusM of a
// route point, and the matched start point must precede the matched end point.
func MatchClimb(route []geodist.Coord, official OfficialClimb, radiusM float64) (MatchedClimb, bool) {
- if math.IsNaN(radiusM) || math.IsInf(radiusM, 0) || radiusM <= 0 {
+ matches := MatchClimbs(route, official, radiusM)
+ if len(matches) == 0 {
return MatchedClimb{}, false
}
- startIndex, startDistance := geo.Nearest(route, official.StartCoord)
- endIndex, endDistance := geo.Nearest(route, official.EndCoord)
- if startDistance > radiusM || endDistance > radiusM {
- return MatchedClimb{}, false
+ return matches[0], true
+}
+
+// MatchClimbs returns all non-overlapping occurrences of an official climb on
+// a route. Each occurrence is defined by a start and end point both within
+// radiusM, with start strictly before end and direction preserved. Loops are
+// handled by clustering contiguous approaches into single candidates and pairing
+// each start cluster with the first end cluster inside its lap window.
+func MatchClimbs(route []geodist.Coord, official OfficialClimb, radiusM float64) []MatchedClimb {
+ if math.IsNaN(radiusM) || math.IsInf(radiusM, 0) || radiusM <= 0 {
+ return nil
}
- if startIndex >= endIndex {
- return MatchedClimb{}, false
+ if len(route) == 0 {
+ return nil
+ }
+ startClusters := clustersInRadius(route, official.StartCoord, radiusM)
+ endClusters := clustersInRadius(route, official.EndCoord, radiusM)
+ if len(startClusters) == 0 || len(endClusters) == 0 {
+ return nil
+ }
+ matches := make([]MatchedClimb, 0)
+ for i, start := range startClusters {
+ nextStartIdx := len(route)
+ if i+1 < len(startClusters) {
+ nextStartIdx = startClusters[i+1].Index
+ }
+ var matchedEnd *clusterCandidate
+ for j := range endClusters {
+ end := &endClusters[j]
+ if end.Index > start.Index && end.Index < nextStartIdx {
+ matchedEnd = end
+ break
+ }
+ }
+ if matchedEnd == nil {
+ continue
+ }
+ matches = append(matches, MatchedClimb{
+ OfficialClimb: official,
+ StartIndex: start.Index,
+ EndIndex: matchedEnd.Index,
+ })
+ }
+ return matches
+}
+
+type clusterCandidate struct {
+ Index int
+ Distance float64
+}
+
+func clustersInRadius(route []geodist.Coord, target geodist.Coord, radiusM float64) []clusterCandidate {
+ candidates := make([]clusterCandidate, 0)
+ inCluster := false
+ bestIndex := 0
+ bestDistance := 0.0
+ for idx, coord := range route {
+ dist := geo.DistanceM(coord, target)
+ if dist <= radiusM {
+ if !inCluster {
+ inCluster = true
+ bestIndex = idx
+ bestDistance = dist
+ } else if dist < bestDistance {
+ bestIndex = idx
+ bestDistance = dist
+ }
+ } else if inCluster {
+ candidates = append(candidates, clusterCandidate{Index: bestIndex, Distance: bestDistance})
+ inCluster = false
+ }
+ }
+ if inCluster {
+ candidates = append(candidates, clusterCandidate{Index: bestIndex, Distance: bestDistance})
}
- return MatchedClimb{OfficialClimb: official, StartIndex: startIndex, EndIndex: endIndex}, true
+ return candidates
}
diff --git a/official_climb/official_climb_test.go b/official_climb/official_climb_test.go
index 2114dd2..a0c8d65 100644
--- a/official_climb/official_climb_test.go
+++ b/official_climb/official_climb_test.go
@@ -138,3 +138,114 @@ func TestMatchClimbRejectsEndpointOutsideRadius(t *testing.T) {
_, found := official_climb.MatchClimb(parsed.Coords, official, 10)
assert.False(t, found)
}
+
+func TestMatchClimbsMatchesTwoLaps(t *testing.T) {
+ coords := []geodist.Coord{
+ {Lat: 43.10, Lon: 5.10},
+ {Lat: 43.11, Lon: 5.11},
+ {Lat: 43.12, Lon: 5.12},
+ {Lat: 43.13, Lon: 5.13},
+ {Lat: 43.10, Lon: 5.10},
+ {Lat: 43.11, Lon: 5.11},
+ {Lat: 43.12, Lon: 5.12},
+ {Lat: 43.13, Lon: 5.13},
+ }
+ parsed := ride.FromColumns(
+ []float64{0, 1000, 2000, 3000, 4000, 5000, 6000, 7000},
+ []float64{100, 150, 200, 100, 100, 150, 200, 100},
+ coords,
+ []time.Time{time.Unix(0, 0), time.Unix(60, 0), time.Unix(120, 0), time.Unix(180, 0), time.Unix(240, 0), time.Unix(300, 0), time.Unix(360, 0), time.Unix(420, 0)},
+ )
+ official := official_climb.OfficialClimb{
+ Name: "Loop",
+ 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)
+ 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)
+ require.True(t, found)
+ assert.Equal(t, 0, single.StartIndex)
+}
+
+func TestMatchClimbsMatchesOnlyCompleteLaps(t *testing.T) {
+ coords := []geodist.Coord{
+ {Lat: 43.10, Lon: 5.10},
+ {Lat: 43.11, Lon: 5.11},
+ {Lat: 43.13, Lon: 5.13},
+ {Lat: 43.10, Lon: 5.10},
+ {Lat: 43.11, Lon: 5.11},
+ {Lat: 43.12, Lon: 5.12},
+ }
+ parsed := ride.FromColumns(
+ []float64{0, 1000, 2000, 3000, 4000, 5000},
+ []float64{100, 150, 100, 100, 150, 200},
+ coords,
+ []time.Time{time.Unix(0, 0), time.Unix(60, 0), time.Unix(120, 0), time.Unix(180, 0), time.Unix(240, 0), time.Unix(300, 0)},
+ )
+ official := official_climb.OfficialClimb{
+ Name: "Partial",
+ 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)
+ require.Len(t, matches, 1)
+ assert.Equal(t, 3, matches[0].StartIndex)
+ assert.Equal(t, 5, matches[0].EndIndex)
+}
+
+func TestMatchClimbsPreservesDirectionAcrossLaps(t *testing.T) {
+ coords := []geodist.Coord{
+ {Lat: 43.12, Lon: 5.12},
+ {Lat: 43.11, Lon: 5.11},
+ {Lat: 43.10, Lon: 5.10},
+ }
+ parsed := ride.FromColumns(
+ []float64{0, 1000, 2000},
+ []float64{200, 150, 100},
+ coords,
+ []time.Time{time.Unix(0, 0), time.Unix(60, 0), time.Unix(120, 0)},
+ )
+ official := official_climb.OfficialClimb{
+ Name: "Directional",
+ 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)
+ assert.Empty(t, matches)
+}
+
+func TestMatchClimbsClustersContiguousPoints(t *testing.T) {
+ coords := []geodist.Coord{
+ {Lat: 43.1000, Lon: 5.1000},
+ {Lat: 43.1001, Lon: 5.1001},
+ {Lat: 43.1001, Lon: 5.1001},
+ {Lat: 43.11, Lon: 5.11},
+ {Lat: 43.12, Lon: 5.12},
+ {Lat: 43.1000, Lon: 5.1000},
+ {Lat: 43.1001, Lon: 5.1001},
+ {Lat: 43.12, Lon: 5.12},
+ }
+ parsed := ride.FromColumns(
+ []float64{0, 100, 200, 1000, 2000, 3000, 3100, 4000},
+ []float64{100, 101, 102, 150, 200, 100, 101, 200},
+ coords,
+ []time.Time{time.Unix(0, 0), time.Unix(10, 0), time.Unix(20, 0), time.Unix(60, 0), time.Unix(120, 0), time.Unix(180, 0), time.Unix(190, 0), time.Unix(240, 0)},
+ )
+ official := official_climb.OfficialClimb{
+ Name: "Clustered",
+ 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)
+ require.Len(t, matches, 2)
+ assert.Equal(t, 0, matches[0].StartIndex)
+ assert.Equal(t, 4, matches[0].EndIndex)
+ assert.Equal(t, 5, matches[1].StartIndex)
+ assert.Equal(t, 7, matches[1].EndIndex)
+}
diff --git a/rideanalysis/analysis.go b/rideanalysis/analysis.go
index fae64b8..90be629 100644
--- a/rideanalysis/analysis.go
+++ b/rideanalysis/analysis.go
@@ -39,13 +39,12 @@ func Analyze(parsed ride.Ride, passes []mountain_pass.MountainPass, officialClim
claimedClimbs := make([]AnalyzedClimb, 0)
claimedRanges := make([]ride.Range, 0)
for _, official := range officialClimbs {
- matched, found := official_climb.MatchClimb(parsed.Coords, official, matchPolicy.EndpointRadiusM)
- if !found {
- continue
+ 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))
}
- 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))
}
+ 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 {