diff options
| -rw-r--r-- | README.md | 3 | ||||
| -rw-r--r-- | official_climb/matcher.go | 84 | ||||
| -rw-r--r-- | official_climb/official_climb_test.go | 111 | ||||
| -rw-r--r-- | rideanalysis/analysis.go | 9 |
4 files changed, 193 insertions, 14 deletions
@@ -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 { |
