From 51613971c1e25dedbc808c66b79d15111d250160 Mon Sep 17 00:00:00 2001 From: Martin Kagamino Lehoux Date: Tue, 18 Aug 2026 11:24:34 +0200 Subject: fix: match official climbs outside detected bounds --- official_climb/matcher.go | 17 ++++++++----- official_climb/official_climb_test.go | 48 +++++++++++++++++++++++++++-------- 2 files changed, 47 insertions(+), 18 deletions(-) (limited to 'official_climb') diff --git a/official_climb/matcher.go b/official_climb/matcher.go index 59c3e0e..e7b51de 100644 --- a/official_climb/matcher.go +++ b/official_climb/matcher.go @@ -7,7 +7,7 @@ import ( "github.com/martinlehoux/biking_home/ride" ) -func MatchClimb(climb ride.Climb, officialClimbs []OfficialClimb, policy MatchPolicy) (OfficialClimb, bool) { +func MatchClimb(route ride.Ride, climb ride.Climb, officialClimbs []OfficialClimb, policy MatchPolicy) (OfficialClimb, bool) { if policy.Validate() != nil { return OfficialClimb{}, false } @@ -15,14 +15,17 @@ func MatchClimb(climb ride.Climb, officialClimbs []OfficialClimb, policy MatchPo var best OfficialClimb found := false for _, official := range officialClimbs { - startIndex, startDistance := nearestClimbPoint(climb, official.StartCoord) - endIndex, endDistance := nearestClimbPoint(climb, official.EndCoord) + 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 @@ -33,11 +36,11 @@ func MatchClimb(climb ride.Climb, officialClimbs []OfficialClimb, policy MatchPo return best, found } -func nearestClimbPoint(climb ride.Climb, target geodist.Coord) (int, float64) { - bestIndex := climb.StartIndex() +func nearestRoutePoint(route ride.Ride, target geodist.Coord) (int, float64) { + bestIndex := 0 bestDistance := math.Inf(1) - for index := climb.StartIndex(); index <= climb.EndIndex(); index++ { - distance := distanceM(climb.PointCoord(index), target) + for index := 0; index < route.Len(); index++ { + distance := distanceM(route.Coord(index), target) if distance < bestDistance { bestIndex = index bestDistance = distance diff --git a/official_climb/official_climb_test.go b/official_climb/official_climb_test.go index 848216c..f8b0fe3 100644 --- a/official_climb/official_climb_test.go +++ b/official_climb/official_climb_test.go @@ -48,11 +48,11 @@ func TestCreateRejectsInvalidOfficialClimb(t *testing.T) { } func TestMatchClimbUsesOrderedCoordinates(t *testing.T) { - climb := testClimb( + parsed, climb := testClimb( geodist.Coord{Lat: 43.1002, Lon: 5.1002}, geodist.Coord{Lat: 43.2002, Lon: 5.2002}, ) - match, found := official_climb.MatchClimb(climb, []official_climb.OfficialClimb{ + 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()) @@ -62,11 +62,11 @@ func TestMatchClimbUsesOrderedCoordinates(t *testing.T) { } func TestMatchClimbRejectsEndpointOutsideRadius(t *testing.T) { - climb := testClimb( + parsed, climb := testClimb( geodist.Coord{Lat: 43.1, Lon: 5.1}, geodist.Coord{Lat: 43.3, Lon: 5.3}, ) - _, found := official_climb.MatchClimb(climb, []official_climb.OfficialClimb{{ + _, 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}, @@ -87,7 +87,7 @@ func TestMatchClimbFindsOfficialEndpointsInsideDetectedClimb(t *testing.T) { []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(climb, []official_climb.OfficialClimb{{ + matched, found := official_climb.MatchClimb(parsed, climb, []official_climb.OfficialClimb{{ Name: "Corrected boundaries", StartCoord: parsed.Coord(1), EndCoord: parsed.Coord(2), @@ -97,6 +97,32 @@ func TestMatchClimbFindsOfficialEndpointsInsideDetectedClimb(t *testing.T) { assert.Equal(t, "Corrected boundaries", matched.Name) } +func TestMatchClimbAllowsOfficialStartBeforeDetectedClimb(t *testing.T) { + parsed := ride.FromColumns( + []float64{0, 1000, 2000, 3000, 4000}, + []float64{100, 120, 300, 500, 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}, + }, + []time.Time{time.Unix(0, 0), time.Unix(60, 0), time.Unix(120, 0), time.Unix(180, 0), time.Unix(240, 0)}, + ) + detected := parsed.ClimbFromIndexes(1, 4) + official := official_climb.OfficialClimb{ + Name: "Wider canonical climb", + StartCoord: parsed.Coord(0), + EndCoord: parsed.Coord(3), + } + + matched, found := official_climb.MatchClimb(parsed, detected, []official_climb.OfficialClimb{official}, official_climb.DefaultMatchPolicy()) + + require.True(t, found) + assert.Equal(t, official.Name, matched.Name) +} + func TestMatchClimbMatchesSameOfficialClimbAcrossRides(t *testing.T) { official := official_climb.OfficialClimb{ Name: "Shared official climb", @@ -127,8 +153,8 @@ func TestMatchClimbMatchesSameOfficialClimbAcrossRides(t *testing.T) { []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.ClimbFromIndexes(0, 3), []official_climb.OfficialClimb{official}, official_climb.DefaultMatchPolicy()) - secondMatch, secondFound := official_climb.MatchClimb(secondRide.ClimbFromIndexes(1, 4), []official_climb.OfficialClimb{official}, official_climb.DefaultMatchPolicy()) + 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) @@ -137,11 +163,11 @@ func TestMatchClimbMatchesSameOfficialClimbAcrossRides(t *testing.T) { } func TestMatchClimbChoosesNearestCandidate(t *testing.T) { - climb := testClimb( + parsed, climb := testClimb( geodist.Coord{Lat: 43.1, Lon: 5.1}, geodist.Coord{Lat: 43.2, Lon: 5.2}, ) - match, found := official_climb.MatchClimb(climb, []official_climb.OfficialClimb{ + 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()) @@ -154,12 +180,12 @@ func TestMatchPolicyRejectsInvalidRadius(t *testing.T) { assert.EqualError(t, (official_climb.MatchPolicy{}).Validate(), "official climb endpoint radius must be greater than zero") } -func testClimb(start, end geodist.Coord) ride.Climb { +func testClimb(start, end geodist.Coord) (ride.Ride, ride.Climb) { 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)}, ) - return parsed.ClimbFromDist(1000, 2000) + return parsed, parsed.ClimbFromDist(1000, 2000) } -- cgit v1.2.3