aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--geo/geo.go25
-rw-r--r--official_climb/matcher.go66
-rw-r--r--official_climb/official_climb_test.go169
-rw-r--r--ride/climb.go43
-rw-r--r--ride/climb_test.go41
-rw-r--r--ride/difficulty_test.go4
-rw-r--r--ride/ride.go8
-rw-r--r--rideanalysis/analysis.go80
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
}