aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-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 {