aboutsummaryrefslogtreecommitdiff
path: root/official_climb
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 /official_climb
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 'official_climb')
-rw-r--r--official_climb/matcher.go66
-rw-r--r--official_climb/official_climb_test.go169
2 files changed, 82 insertions, 153 deletions
diff --git a/official_climb/matcher.go b/official_climb/matcher.go
index e7b51de..faf51a7 100644
--- a/official_climb/matcher.go
+++ b/official_climb/matcher.go
@@ -4,52 +4,32 @@ import (
"math"
"github.com/jftuga/geodist"
- "github.com/martinlehoux/biking_home/ride"
+ "github.com/martinlehoux/biking_home/geo"
)
-func MatchClimb(route ride.Ride, climb ride.Climb, officialClimbs []OfficialClimb, policy MatchPolicy) (OfficialClimb, bool) {
- if policy.Validate() != nil {
- return OfficialClimb{}, false
- }
- bestDistance := math.Inf(1)
- var best OfficialClimb
- found := false
- for _, official := range officialClimbs {
- startIndex, startDistance := nearestRoutePoint(route, official.StartCoord)
- endIndex, endDistance := nearestRoutePoint(route, official.EndCoord)
- if startDistance > policy.EndpointRadiusM || endDistance > policy.EndpointRadiusM {
- continue
- }
- if startIndex >= endIndex {
- continue
- }
- if endIndex < climb.StartIndex() || startIndex > climb.EndIndex() {
- continue
- }
- totalDistance := startDistance + endDistance
- if totalDistance < bestDistance || (totalDistance == bestDistance && (!found || official.ID < best.ID)) {
- best = official
- bestDistance = totalDistance
- found = true
- }
- }
- return best, found
+// MatchedClimb is an official climb that lies on a route, together with the
+// route point indexes of its start and end. It lets the climb detector know
+// which segment of the route is already claimed by an official climb.
+type MatchedClimb struct {
+ OfficialClimb
+ StartIndex int
+ EndIndex int
}
-func nearestRoutePoint(route ride.Ride, target geodist.Coord) (int, float64) {
- bestIndex := 0
- bestDistance := math.Inf(1)
- for index := 0; index < route.Len(); index++ {
- distance := distanceM(route.Coord(index), target)
- if distance < bestDistance {
- bestIndex = index
- bestDistance = distance
- }
+// MatchClimb matches an official climb against a route purely by
+// 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 {
+ return MatchedClimb{}, false
}
- return bestIndex, bestDistance
-}
-
-func distanceM(a, b geodist.Coord) float64 {
- _, distanceKm := geodist.HaversineDistance(a, b)
- return distanceKm * 1000
+ startIndex, startDistance := geo.Nearest(route, official.StartCoord)
+ endIndex, endDistance := geo.Nearest(route, official.EndCoord)
+ if startDistance > radiusM || endDistance > radiusM {
+ return MatchedClimb{}, false
+ }
+ if startIndex >= endIndex {
+ return MatchedClimb{}, false
+ }
+ return MatchedClimb{OfficialClimb: official, StartIndex: startIndex, EndIndex: endIndex}, true
}
diff --git a/official_climb/official_climb_test.go b/official_climb/official_climb_test.go
index f8b0fe3..2114dd2 100644
--- a/official_climb/official_climb_test.go
+++ b/official_climb/official_climb_test.go
@@ -47,145 +47,94 @@ func TestCreateRejectsInvalidOfficialClimb(t *testing.T) {
assert.EqualError(t, err, "official climb name is required")
}
-func TestMatchClimbUsesOrderedCoordinates(t *testing.T) {
- parsed, climb := testClimb(
- geodist.Coord{Lat: 43.1002, Lon: 5.1002},
- geodist.Coord{Lat: 43.2002, Lon: 5.2002},
- )
- match, found := official_climb.MatchClimb(parsed, climb, []official_climb.OfficialClimb{
- {Name: "Same direction", StartCoord: geodist.Coord{Lat: 43.1, Lon: 5.1}, EndCoord: geodist.Coord{Lat: 43.2, Lon: 5.2}},
- {Name: "Reverse direction", StartCoord: geodist.Coord{Lat: 43.2, Lon: 5.2}, EndCoord: geodist.Coord{Lat: 43.1, Lon: 5.1}},
- }, official_climb.DefaultMatchPolicy())
-
- require.True(t, found)
- assert.Equal(t, "Same direction", match.Name)
-}
-
-func TestMatchClimbRejectsEndpointOutsideRadius(t *testing.T) {
- parsed, climb := testClimb(
- geodist.Coord{Lat: 43.1, Lon: 5.1},
- geodist.Coord{Lat: 43.3, Lon: 5.3},
- )
- _, found := official_climb.MatchClimb(parsed, climb, []official_climb.OfficialClimb{{
- Name: "Partial match",
- StartCoord: geodist.Coord{Lat: 43.1, Lon: 5.1},
- EndCoord: geodist.Coord{Lat: 43.2, Lon: 5.2},
- }}, official_climb.DefaultMatchPolicy())
- assert.False(t, found)
+func TestMatchPolicyRejectsInvalidRadius(t *testing.T) {
+ assert.EqualError(t, (official_climb.MatchPolicy{}).Validate(), "official climb endpoint radius must be greater than zero")
}
-func TestMatchClimbFindsOfficialEndpointsInsideDetectedClimb(t *testing.T) {
+func TestMatchClimbMatchesWithoutDetectedClimb(t *testing.T) {
parsed := ride.FromColumns(
[]float64{0, 1000, 2000, 3000},
- []float64{100, 300, 500, 100},
+ []float64{100, 150, 200, 100},
[]geodist.Coord{
- {Lat: 43.1, Lon: 5.1},
- {Lat: 43.101, Lon: 5.101},
- {Lat: 43.102, Lon: 5.102},
- {Lat: 43.103, Lon: 5.103},
+ {Lat: 43.10, Lon: 5.10},
+ {Lat: 43.11, Lon: 5.11},
+ {Lat: 43.12, Lon: 5.12},
+ {Lat: 43.13, Lon: 5.13},
},
[]time.Time{time.Unix(0, 0), time.Unix(60, 0), time.Unix(120, 0), time.Unix(180, 0)},
)
- climb := parsed.ClimbFromIndexes(0, 3)
- matched, found := official_climb.MatchClimb(parsed, climb, []official_climb.OfficialClimb{{
- Name: "Corrected boundaries",
+ official := official_climb.OfficialClimb{
+ Name: "On route",
StartCoord: parsed.Coord(1),
EndCoord: parsed.Coord(2),
- }}, official_climb.DefaultMatchPolicy())
-
+ }
+ matched, found := official_climb.MatchClimb(parsed.Coords, official, official_climb.DefaultMatchRadiusM)
require.True(t, found)
- assert.Equal(t, "Corrected boundaries", matched.Name)
+ assert.Equal(t, 1, matched.StartIndex)
+ assert.Equal(t, 2, matched.EndIndex)
}
-func TestMatchClimbAllowsOfficialStartBeforeDetectedClimb(t *testing.T) {
+func TestMatchClimbMatchesEndpointInsideRadius(t *testing.T) {
parsed := ride.FromColumns(
- []float64{0, 1000, 2000, 3000, 4000},
- []float64{100, 120, 300, 500, 100},
+ []float64{0, 1000, 2000, 3000},
+ []float64{100, 150, 200, 100},
[]geodist.Coord{
- {Lat: 43.100, Lon: 5.100},
- {Lat: 43.101, Lon: 5.101},
- {Lat: 43.102, Lon: 5.102},
- {Lat: 43.103, Lon: 5.103},
- {Lat: 43.104, Lon: 5.104},
+ {Lat: 43.10, Lon: 5.10},
+ {Lat: 43.11, Lon: 5.11},
+ {Lat: 43.12, Lon: 5.12},
+ {Lat: 43.13, Lon: 5.13},
},
- []time.Time{time.Unix(0, 0), time.Unix(60, 0), time.Unix(120, 0), time.Unix(180, 0), time.Unix(240, 0)},
+ []time.Time{time.Unix(0, 0), time.Unix(60, 0), time.Unix(120, 0), time.Unix(180, 0)},
)
- detected := parsed.ClimbFromIndexes(1, 4)
official := official_climb.OfficialClimb{
- Name: "Wider canonical climb",
- StartCoord: parsed.Coord(0),
- EndCoord: parsed.Coord(3),
+ Name: "Offset from route",
+ StartCoord: geodist.Coord{Lat: 43.1101, Lon: 5.1101},
+ EndCoord: geodist.Coord{Lat: 43.1201, Lon: 5.1201},
}
-
- matched, found := official_climb.MatchClimb(parsed, detected, []official_climb.OfficialClimb{official}, official_climb.DefaultMatchPolicy())
-
+ matched, found := official_climb.MatchClimb(parsed.Coords, official, official_climb.DefaultMatchRadiusM)
require.True(t, found)
- assert.Equal(t, official.Name, matched.Name)
+ assert.Equal(t, 1, matched.StartIndex)
+ assert.Equal(t, 2, matched.EndIndex)
}
-func TestMatchClimbMatchesSameOfficialClimbAcrossRides(t *testing.T) {
- official := official_climb.OfficialClimb{
- Name: "Shared official climb",
- StartCoord: geodist.Coord{Lat: 43.100, Lon: 5.100},
- EndCoord: geodist.Coord{Lat: 43.200, Lon: 5.200},
- }
- firstRide := ride.FromColumns(
+func TestMatchClimbRejectsReversedEndpoints(t *testing.T) {
+ parsed := ride.FromColumns(
[]float64{0, 1000, 2000, 3000},
- []float64{100, 300, 500, 100},
+ []float64{100, 150, 200, 100},
[]geodist.Coord{
- {Lat: 43.090, Lon: 5.090},
- official.StartCoord,
- official.EndCoord,
- {Lat: 43.210, Lon: 5.210},
+ {Lat: 43.10, Lon: 5.10},
+ {Lat: 43.11, Lon: 5.11},
+ {Lat: 43.12, Lon: 5.12},
+ {Lat: 43.13, Lon: 5.13},
},
[]time.Time{time.Unix(0, 0), time.Unix(60, 0), time.Unix(120, 0), time.Unix(180, 0)},
)
- secondRide := ride.FromColumns(
- []float64{0, 750, 1500, 2250, 3000},
- []float64{100, 180, 300, 500, 100},
- []geodist.Coord{
- {Lat: 43.080, Lon: 5.080},
- {Lat: 43.095, Lon: 5.095},
- official.StartCoord,
- official.EndCoord,
- {Lat: 43.205, Lon: 5.205},
- },
- []time.Time{time.Unix(0, 0), time.Unix(45, 0), time.Unix(90, 0), time.Unix(135, 0), time.Unix(180, 0)},
- )
-
- firstMatch, firstFound := official_climb.MatchClimb(firstRide, firstRide.ClimbFromIndexes(0, 3), []official_climb.OfficialClimb{official}, official_climb.DefaultMatchPolicy())
- secondMatch, secondFound := official_climb.MatchClimb(secondRide, secondRide.ClimbFromIndexes(1, 4), []official_climb.OfficialClimb{official}, official_climb.DefaultMatchPolicy())
-
- require.True(t, firstFound)
- require.True(t, secondFound)
- assert.Equal(t, official.Name, firstMatch.Name)
- assert.Equal(t, official.Name, secondMatch.Name)
-}
-
-func TestMatchClimbChoosesNearestCandidate(t *testing.T) {
- parsed, climb := testClimb(
- geodist.Coord{Lat: 43.1, Lon: 5.1},
- geodist.Coord{Lat: 43.2, Lon: 5.2},
- )
- match, found := official_climb.MatchClimb(parsed, climb, []official_climb.OfficialClimb{
- {ID: 2, Name: "Farther", StartCoord: geodist.Coord{Lat: 43.1005, Lon: 5.1005}, EndCoord: geodist.Coord{Lat: 43.2005, Lon: 5.2005}},
- {ID: 1, Name: "Nearest", StartCoord: geodist.Coord{Lat: 43.1001, Lon: 5.1001}, EndCoord: geodist.Coord{Lat: 43.2001, Lon: 5.2001}},
- }, official_climb.DefaultMatchPolicy())
-
- require.True(t, found)
- assert.Equal(t, "Nearest", match.Name)
-}
-
-func TestMatchPolicyRejectsInvalidRadius(t *testing.T) {
- assert.EqualError(t, (official_climb.MatchPolicy{}).Validate(), "official climb endpoint radius must be greater than zero")
+ official := official_climb.OfficialClimb{
+ Name: "Reversed",
+ StartCoord: parsed.Coord(2),
+ EndCoord: parsed.Coord(1),
+ }
+ _, found := official_climb.MatchClimb(parsed.Coords, official, official_climb.DefaultMatchRadiusM)
+ assert.False(t, found)
}
-func testClimb(start, end geodist.Coord) (ride.Ride, ride.Climb) {
+func TestMatchClimbRejectsEndpointOutsideRadius(t *testing.T) {
parsed := ride.FromColumns(
- []float64{0, 1000, 2000},
- []float64{100, 150, 200},
- []geodist.Coord{{}, start, end},
- []time.Time{time.Unix(0, 0), time.Unix(60, 0), time.Unix(120, 0)},
+ []float64{0, 1000, 2000, 3000},
+ []float64{100, 150, 200, 100},
+ []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},
+ },
+ []time.Time{time.Unix(0, 0), time.Unix(60, 0), time.Unix(120, 0), time.Unix(180, 0)},
)
- return parsed, parsed.ClimbFromDist(1000, 2000)
+ official := official_climb.OfficialClimb{
+ Name: "Too far",
+ StartCoord: geodist.Coord{Lat: 43.09, Lon: 5.09},
+ EndCoord: parsed.Coord(2),
+ }
+ _, found := official_climb.MatchClimb(parsed.Coords, official, 10)
+ assert.False(t, found)
}