diff options
| -rw-r--r-- | geo/geo.go | 25 | ||||
| -rw-r--r-- | official_climb/matcher.go | 66 | ||||
| -rw-r--r-- | official_climb/official_climb_test.go | 169 | ||||
| -rw-r--r-- | ride/climb.go | 43 | ||||
| -rw-r--r-- | ride/climb_test.go | 41 | ||||
| -rw-r--r-- | ride/difficulty_test.go | 4 | ||||
| -rw-r--r-- | ride/ride.go | 8 | ||||
| -rw-r--r-- | rideanalysis/analysis.go | 80 |
8 files changed, 222 insertions, 214 deletions
diff --git a/geo/geo.go b/geo/geo.go new file mode 100644 index 0000000..0fcff14 --- /dev/null +++ b/geo/geo.go @@ -0,0 +1,25 @@ +package geo + +import ( + "math" + + "github.com/jftuga/geodist" +) + +func DistanceM(a, b geodist.Coord) float64 { + _, distanceKm := geodist.HaversineDistance(a, b) + return distanceKm * 1000 +} + +func Nearest(coords []geodist.Coord, target geodist.Coord) (int, float64) { + bestIndex := 0 + bestDistance := math.Inf(1) + for index := 0; index < len(coords); index++ { + distance := DistanceM(coords[index], target) + if distance < bestDistance { + bestIndex = index + bestDistance = distance + } + } + return bestIndex, bestDistance +} 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) } diff --git a/ride/climb.go b/ride/climb.go index 29a4dcf..88b9d3b 100644 --- a/ride/climb.go +++ b/ride/climb.go @@ -221,8 +221,47 @@ func climbsBetween(r Ride, start, end int) []Climb { return climbs } -func (ride *Ride) AllClimbs() []Climb { - climbs := climbsBetween(*ride, 0, ride.Len()-1) +type Range struct { + Start int + End int +} + +func isValidClaim(claim Range, routeLen int) bool { + return claim.Start >= 0 && claim.End < routeLen && claim.Start < claim.End +} + +// freeRegions returns the maximal sub-ranges of [rStart, rEnd] that are not +// covered by any claimed range, after discarding claims that fall outside. +func freeRegions(rStart, rEnd int, claimed []Range) []Range { + regions := make([]Range, 0, len(claimed)+1) + current := rStart + for _, claim := range claimed { + if !isValidClaim(claim, rEnd+1) { + continue + } + if claim.End < current { + continue + } + if claim.Start > current { + regions = append(regions, Range{Start: current, End: claim.Start - 1}) + } + if claim.End+1 > current { + current = claim.End + 1 + } + } + if current <= rEnd { + regions = append(regions, Range{Start: current, End: rEnd}) + } + return regions +} + +// returns the detected climbs of the whole ride, but never a climb that overlaps +// a claimed range. +func (ride *Ride) DetectClimbs(claimed []Range) []Climb { + climbs := make([]Climb, 0) + for _, region := range freeRegions(0, ride.Len()-1, claimed) { + climbs = append(climbs, climbsBetween(*ride, region.Start, region.End)...) + } slices.SortFunc(climbs, climbCmpStart) return climbs } diff --git a/ride/climb_test.go b/ride/climb_test.go index feca001..a5e332d 100644 --- a/ride/climb_test.go +++ b/ride/climb_test.go @@ -10,6 +10,7 @@ import ( "github.com/martinlehoux/biking_home/ride" "github.com/martinlehoux/kagamigo/kcore" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) type RideBuilderSection struct { @@ -66,7 +67,7 @@ var parser = ride.GPXRideParser{} func TestClimbThenFalseFlat(t *testing.T) { r := RideBuilder{precision: 100}.WithSection("2km at 7%").WithSection("10km at 1%").Build() - climbs := r.AllClimbs() + climbs := r.DetectClimbs(nil) assert.Len(t, climbs, 1) assert.Equal(t, "0.0km-2.0km: 2.0km at 7.0% (98 pts - Cat 3)", climbs[0].String()) @@ -74,7 +75,7 @@ func TestClimbThenFalseFlat(t *testing.T) { func TestSmallClimbWithDescentInsideFalseFlat(t *testing.T) { r := RideBuilder{precision: 100}.WithSection("10km at 1%").WithSection("2km at 7%").WithSection("1km at -7%").WithSection("10km at 1%").Build() - climbs := r.AllClimbs() + climbs := r.DetectClimbs(nil) assert.Len(t, climbs, 1) assert.Equal(t, "10.0km-12.0km: 2.0km at 7.0% (98 pts - Cat 3)", climbs[0].String()) @@ -83,7 +84,7 @@ func TestSmallClimbWithDescentInsideFalseFlat(t *testing.T) { func TestPogacar20220721(t *testing.T) { r, err := ride.ParseFile(parser, "../examples/2022-07-21.Pogacar.gpx") assert.NoError(t, err) - climbs := r.AllClimbs() + climbs := r.DetectClimbs(nil) assert.Len(t, climbs, 6) assert.Equal(t, "0.6km-5.6km: 5.0km at 3.5% (92 pts - Cat 3)", climbs[0].String()) @@ -97,7 +98,7 @@ func TestPogacar20220721(t *testing.T) { func TestBouclesVerdon2024(t *testing.T) { r, err := ride.ParseFile(parser, "../examples/2024-06-29.BouclesVerdon.gpx") assert.NoError(t, err) - climbs := r.AllClimbs() + climbs := r.DetectClimbs(nil) assert.Len(t, climbs, 6) assert.Equal(t, "3.6km-13.8km: 10.3km at 2.1% (98 pts - Cat 3)", climbs[0].String()) // TODO: Not steep enough @@ -111,7 +112,7 @@ func TestBouclesVerdon2024(t *testing.T) { func TestMimetArbois20241229(t *testing.T) { r, err := ride.ParseFile(parser, "../examples/2024-12-29.MimetArbois.gpx") assert.NoError(t, err) - climbs := r.AllClimbs() + climbs := r.DetectClimbs(nil) assert.Len(t, climbs, 2) assert.Equal(t, "3.9km-5.3km: 1.4km at 10.3% (165 pts - Cat 3)", climbs[0].String()) @@ -121,7 +122,7 @@ func TestMimetArbois20241229(t *testing.T) { func TestMimetArbois20250104(t *testing.T) { r, err := ride.ParseFile(parser, "../examples/2025-01-04.MimetArbois.gpx") assert.NoError(t, err) - climbs := r.AllClimbs() + climbs := r.DetectClimbs(nil) assert.Len(t, climbs, 2) assert.Equal(t, "4.5km-5.9km: 1.4km at 10.1% (159 pts - Cat 3)", climbs[0].String()) @@ -132,7 +133,7 @@ func TestMimetArbois20250104(t *testing.T) { func TestAlpesVerdonTour20230617(t *testing.T) { r, err := ride.ParseFile(parser, "../examples/2023-06-17.AlpesVerdonTour.gpx") assert.NoError(t, err) - climbs := r.AllClimbs() + climbs := r.DetectClimbs(nil) assert.Len(t, climbs, 6) assert.Equal(t, "0.7km-18.6km: 17.9km at 3.5% (364 pts - Cat 1)", climbs[0].String()) // TODO: Should split in 2 (10km at 4.5% is Cat 2) @@ -142,3 +143,29 @@ func TestAlpesVerdonTour20230617(t *testing.T) { assert.Equal(t, "86.7km-92.8km: 6.1km at 2.7% (60 pts - Cat 4)", climbs[4].String()) assert.Equal(t, "114.1km-114.7km: 0.6km at 14.3% (119 pts - Cat 3)", climbs[5].String()) } + +func TestDetectClimbsSkipsMiddleClaim(t *testing.T) { + // A climb, a false flat giving a saddle, then a climb again. Claiming the + // saddle's descent base should leave both climbs detected separately. + r := RideBuilder{precision: 100}.WithSection("2km at 7%").WithSection("1km at -7%").WithSection("2km at 7%").Build() + climbs := r.DetectClimbs([]ride.Range{{Start: 20, End: 20}}) + + // No free region falls under the min distance except the two climbs. + assert.Len(t, climbs, 2) + assert.Equal(t, "0.0km-2.0km: 2.0km at 7.0% (98 pts - Cat 3)", climbs[0].String()) + assert.Equal(t, "3.0km-5.0km: 2.0km at 7.0% (98 pts - Cat 3)", climbs[1].String()) +} + +func TestDetectClimbsOverlappingClaimDropsClimb(t *testing.T) { + // Claiming the ascent region entirely swallows the climb. + r := RideBuilder{precision: 100}.WithSection("2km at 7%").WithSection("20km at 0%").Build() + climbs := r.DetectClimbs([]ride.Range{{Start: 0, End: 40}}) + assert.Empty(t, climbs) +} + +func TestDetectClimbsIgnoresOutOfRangeClaim(t *testing.T) { + r := RideBuilder{precision: 100}.WithSection("2km at 7%").WithSection("20km at 0%").Build() + climbs := r.DetectClimbs([]ride.Range{{Start: -5, End: -1}}) + require.Len(t, climbs, 1) + assert.Equal(t, "0.0km-2.0km: 2.0km at 7.0% (98 pts - Cat 3)", climbs[0].String()) +} diff --git a/ride/difficulty_test.go b/ride/difficulty_test.go index 5773376..05b60f9 100644 --- a/ride/difficulty_test.go +++ b/ride/difficulty_test.go @@ -44,7 +44,7 @@ func TestCotacolExampleRide(t *testing.T) { func TestCotacolClimbNotAtStart(t *testing.T) { r := RideBuilder{precision: 100}.WithSection("1km at 0%").WithSection("2km at 7%").Build() - climbs := r.AllClimbs() + climbs := r.DetectClimbs(nil) assert.Len(t, climbs, 1) assert.InDelta(t, 98.0, climbs[0].DifficultyScore(), 1e-9) } @@ -78,7 +78,7 @@ func TestCategoryOfBoundaries(t *testing.T) { func TestCotacolHautacamClimbfinderReference(t *testing.T) { r, err := ride.ParseFile(parser, "../examples/2022-07-21.Pogacar.gpx") assert.NoError(t, err) - climbs := r.AllClimbs() + climbs := r.DetectClimbs(nil) assert.Len(t, climbs, 6) hautacam := climbs[5] assert.InDelta(t, 128.6, hautacam.StartDistanceM()/1000, 0.1) diff --git a/ride/ride.go b/ride/ride.go index a10d8a2..d5533ae 100644 --- a/ride/ride.go +++ b/ride/ride.go @@ -22,7 +22,7 @@ var ErrNoTrackPoints = errors.New("ride has no track points") type Ride struct { distances []float64 elevations []float64 - coords []geodist.Coord + Coords []geodist.Coord timestamps []time.Time heartRates []float64 cadences []float64 @@ -32,7 +32,7 @@ type Ride struct { func (r *Ride) check() { kcore.Assert(r.Len() > 0, "no points in ride") kcore.Assert(len(r.distances) == len(r.elevations), "ride columns have different lengths") - kcore.Assert(len(r.distances) == len(r.coords), "ride columns have different lengths") + kcore.Assert(len(r.distances) == len(r.Coords), "ride columns have different lengths") kcore.Assert(len(r.distances) == len(r.timestamps), "ride columns have different lengths") for _, column := range [][]float64{r.heartRates, r.cadences, r.powers} { if len(column) > 0 { @@ -49,7 +49,7 @@ func (r Ride) DistanceM(i int) float64 { return r.distances[i] } func (r Ride) ElevationM(i int) float64 { return r.elevations[i] } -func (r Ride) Coord(i int) geodist.Coord { return r.coords[i] } +func (r Ride) Coord(i int) geodist.Coord { return r.Coords[i] } func (r Ride) Timestamp(i int) time.Time { return r.timestamps[i] } @@ -171,7 +171,7 @@ func fromColumns(distances []float64, elevations []float64, coords []geodist.Coo ride := Ride{ distances: distances, elevations: elevations, - coords: coords, + Coords: coords, timestamps: timestamps, heartRates: heartRates, cadences: cadences, diff --git a/rideanalysis/analysis.go b/rideanalysis/analysis.go index a4c319f..f055676 100644 --- a/rideanalysis/analysis.go +++ b/rideanalysis/analysis.go @@ -1,9 +1,8 @@ package rideanalysis import ( - "math" + "slices" - "github.com/jftuga/geodist" "github.com/martinlehoux/biking_home/mountain_pass" "github.com/martinlehoux/biking_home/official_climb" "github.com/martinlehoux/biking_home/ride" @@ -37,64 +36,53 @@ func Analyze(parsed ride.Ride, passes []mountain_pass.MountainPass, officialClim Climbs: make([]AnalyzedClimb, 0), Crossings: make([]mountain_pass.Crossing, 0), } - for _, detectedClimb := range parsed.AllClimbs() { + 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 + } + 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)) + } + for _, detectedClimb := range parsed.DetectClimbs(claimedRanges) { displayClimb := detectedClimb if matchedPass, ok := mountain_pass.MatchClimb(displayClimb, passes, passMatchRadiusM, passMatchElevationToleranceM); ok { displayClimb.Name = matchedPass.Name } - matchedOfficial, officialFound := official_climb.MatchClimb(parsed, detectedClimb, officialClimbs, matchPolicy) - if officialFound { - startIndex := nearestCoordIndex(parsed, matchedOfficial.StartCoord) - endIndex := nearestCoordIndex(parsed, matchedOfficial.EndCoord) - if startIndex < endIndex { - displayClimb = parsed.ClimbFromIndexes(startIndex, endIndex) - displayClimb.Name = matchedOfficial.Name - } else { - officialFound = false - matchedOfficial = official_climb.OfficialClimb{} - } - } - cotacol := displayClimb.DifficultyScore() - result.Climbs = append(result.Climbs, AnalyzedClimb{ - Segment: displayClimb, - Name: displayClimb.Name, - Category: ride.CategoryOf(cotacol).String(), - DistanceM: displayClimb.EndDistanceM() - displayClimb.StartDistanceM(), - SlopePercent: ride.Slope(parsed, displayClimb.StartIndex(), displayClimb.EndIndex()) * 100, - Cotacol: cotacol, - OfficialClimbID: officialClimbID(matchedOfficial, officialFound), - OfficialName: officialClimbName(matchedOfficial, officialFound), - }) + result.Climbs = append(result.Climbs, analyzedClimb(parsed, displayClimb, displayClimb.Name, 0)) } + climbs := make([]AnalyzedClimb, 0, len(claimedClimbs)+len(result.Climbs)) + climbs = append(climbs, claimedClimbs...) + climbs = append(climbs, result.Climbs...) + slices.SortFunc(climbs, func(a, b AnalyzedClimb) int { + return a.Segment.StartIndex() - b.Segment.StartIndex() + }) + result.Climbs = climbs for _, crossing := range mountain_pass.DetectCrossings(parsed, passes, passCrossingRadiusM, passCrossingElevationToleranceM) { result.Crossings = append(result.Crossings, crossing) } return result } -func nearestCoordIndex(parsed ride.Ride, target geodist.Coord) int { - bestIndex := 0 - bestDistance := math.Inf(1) - for index := 0; index < parsed.Len(); index++ { - _, distanceKm := geodist.HaversineDistance(parsed.Coord(index), target) - if distanceKm < bestDistance { - bestDistance = distanceKm - bestIndex = index - } - } - return bestIndex -} - -func officialClimbID(climb official_climb.OfficialClimb, found bool) int64 { - if !found { - return 0 +func analyzedClimb(parsed ride.Ride, segment ride.Climb, name string, officialID int64) AnalyzedClimb { + cotacol := segment.DifficultyScore() + return AnalyzedClimb{ + Segment: segment, + Name: name, + Category: ride.CategoryOf(cotacol).String(), + DistanceM: segment.EndDistanceM() - segment.StartDistanceM(), + SlopePercent: ride.Slope(parsed, segment.StartIndex(), segment.EndIndex()) * 100, + Cotacol: cotacol, + OfficialClimbID: officialID, + OfficialName: officialClimbNameForID(name, officialID), } - return climb.ID } -func officialClimbName(climb official_climb.OfficialClimb, found bool) string { - if !found { +func officialClimbNameForID(name string, officialID int64) string { + if officialID == 0 { return "" } - return climb.Name + return name } |
