From 5e3d39cda5250cb55bc8324bcf96b0ab4b36c47f Mon Sep 17 00:00:00 2001 From: Martin Kagamino Lehoux Date: Fri, 21 Aug 2026 15:30:51 +0200 Subject: 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 --- official_climb/matcher.go | 66 +++++-------- official_climb/official_climb_test.go | 169 ++++++++++++---------------------- 2 files changed, 82 insertions(+), 153 deletions(-) (limited to 'official_climb') 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) } -- cgit v1.3