aboutsummaryrefslogtreecommitdiff
path: root/rideanalysis
diff options
context:
space:
mode:
Diffstat (limited to 'rideanalysis')
-rw-r--r--rideanalysis/profile.go72
-rw-r--r--rideanalysis/profile_test.go56
2 files changed, 128 insertions, 0 deletions
diff --git a/rideanalysis/profile.go b/rideanalysis/profile.go
new file mode 100644
index 0000000..d9fd94f
--- /dev/null
+++ b/rideanalysis/profile.go
@@ -0,0 +1,72 @@
+package rideanalysis
+
+import (
+ "math"
+
+ "github.com/jftuga/geodist"
+ "github.com/martinlehoux/biking_home/climb"
+ "github.com/martinlehoux/biking_home/official_climb"
+ "github.com/martinlehoux/biking_home/ride"
+)
+
+// ClimbProfile is the chart profile of one official climb, merged from the rides that recorded it.
+type ClimbProfile struct {
+ DistanceM float64
+ Route []geodist.Coord
+ Sections []climb.ProfileSection
+}
+
+// MergeClimbProfile merges every recorded passage of an official climb into one profile: the median
+// elevation at every step of an axis as long as the climb's aggregate, the recorded route of the
+// median-length passage (full precision, no resampling), and that trace cut into display sections.
+func MergeClimbProfile(officialClimb official_climb.OfficialClimb, rides []ride.Ride, aggregate official_climb.Aggregate, matchPolicy official_climb.MatchPolicy, profilePolicy climb.ProfilePolicy) ClimbProfile {
+ profile := ClimbProfile{DistanceM: aggregate.DistanceM}
+ elevationSamples := make([][]float64, 0, len(rides))
+ passages := make([]climb.Climb, 0, len(rides))
+ for _, parsed := range rides {
+ for _, matched := range official_climb.MatchClimbs(parsed.Route, officialClimb, matchPolicy.EndpointRadiusM) {
+ passage := climb.Climb{Route: parsed.Route.Slice(matched.StartIndex, matched.EndIndex)}
+ if sample := official_climb.NormalizeProfile(passage); len(sample) > 0 {
+ elevationSamples = append(elevationSamples, sample)
+ }
+ passages = append(passages, passage)
+ }
+ }
+ profile.Route = medianPassageRoute(passages)
+ profile.Sections = profileRoute(aggregate.DistanceM, official_climb.MedianProfile(elevationSamples)).ProfileSections(profilePolicy)
+ return profile
+}
+
+// medianPassageRoute picks the recorded route of the passage whose length is the median: robust to
+// one drifted ride.
+func medianPassageRoute(passages []climb.Climb) []geodist.Coord {
+ if len(passages) == 0 {
+ return nil
+ }
+ distances := make([]float64, len(passages))
+ for index, passage := range passages {
+ distances[index] = passage.Distances[len(passage.Distances)-1] - passage.Distances[0]
+ }
+ median := official_climb.Median(distances)
+ best := passages[0]
+ bestDiff := math.Abs(distances[0] - median)
+ for index := 1; index < len(passages); index++ {
+ if diff := math.Abs(distances[index] - median); diff < bestDiff {
+ best = passages[index]
+ bestDiff = diff
+ }
+ }
+ return best.Coords
+}
+
+// profileRoute lays the merged elevation samples on a uniform axis as long as the climb.
+func profileRoute(distanceM float64, elevations []float64) climb.Route {
+ if len(elevations) < 2 {
+ return climb.Route{}
+ }
+ route := climb.Route{Distances: make([]float64, len(elevations)), Elevations: elevations}
+ for index := range elevations {
+ route.Distances[index] = distanceM * float64(index) / float64(len(elevations)-1)
+ }
+ return route
+}
diff --git a/rideanalysis/profile_test.go b/rideanalysis/profile_test.go
new file mode 100644
index 0000000..5fb0772
--- /dev/null
+++ b/rideanalysis/profile_test.go
@@ -0,0 +1,56 @@
+package rideanalysis_test
+
+import (
+ "testing"
+
+ "github.com/martinlehoux/biking_home/climb"
+ "github.com/martinlehoux/biking_home/internal/ridetest"
+ "github.com/martinlehoux/biking_home/official_climb"
+ "github.com/martinlehoux/biking_home/ride"
+ "github.com/martinlehoux/biking_home/rideanalysis"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestMergeClimbProfileMediansRecordedPassages(t *testing.T) {
+ parsed, officialClimb := attemptRideForClimb(t)
+ officialClimb.ID = 42
+ // Same trace 10 m higher: the merge must average the two passages, not pick one.
+ shifted := ridetest.MustRide("1km at 14.7% | 1km at -6.7%", ridetest.StartCoord(43.61, 5.42), ridetest.StartElevation(310), ridetest.Sampling(1000), ridetest.StartTime(attemptRideStart))
+ analysis := rideanalysis.Analyze(parsed, nil, []official_climb.OfficialClimb{officialClimb}, official_climb.DefaultMatchPolicy())
+ aggregate := official_climb.AggregateAttempts(rideanalysis.Attempts(1, analysis, parsed))
+
+ profile := rideanalysis.MergeClimbProfile(officialClimb, []ride.Ride{parsed, shifted}, aggregate, official_climb.DefaultMatchPolicy(), climb.DefaultProfilePolicy())
+
+ assert.InDelta(t, 1000, profile.DistanceM, 0.01)
+ require.NotEmpty(t, profile.Sections)
+ assert.InDelta(t, 305, profile.Sections[0].StartElevationM, 1)
+ lastSection := profile.Sections[len(profile.Sections)-1]
+ assert.InDelta(t, 452, lastSection.EndElevationM, 2)
+ assert.InDelta(t, 1000, lastSection.EndDistanceM, 0.01)
+}
+
+func TestMergeClimbProfileRouteKeepsRecordedPoints(t *testing.T) {
+ parsed, officialClimb := attemptRideForClimb(t)
+ officialClimb.ID = 42
+ analysis := rideanalysis.Analyze(parsed, nil, []official_climb.OfficialClimb{officialClimb}, official_climb.DefaultMatchPolicy())
+ aggregate := official_climb.AggregateAttempts(rideanalysis.Attempts(1, analysis, parsed))
+
+ profile := rideanalysis.MergeClimbProfile(officialClimb, []ride.Ride{parsed}, aggregate, official_climb.DefaultMatchPolicy(), climb.DefaultProfilePolicy())
+
+ // Full recorded precision: every matched segment point is present, not a fixed resample.
+ require.Len(t, profile.Route, 2)
+ assert.InDelta(t, parsed.Coords[0].Lon, profile.Route[0].Lon, 1e-9)
+ assert.InDelta(t, parsed.Coords[0].Lat, profile.Route[0].Lat, 1e-9)
+ assert.InDelta(t, parsed.Coords[1].Lon, profile.Route[1].Lon, 1e-9)
+ assert.InDelta(t, parsed.Coords[1].Lat, profile.Route[1].Lat, 1e-9)
+}
+
+func TestMergeClimbProfileWithoutRides(t *testing.T) {
+ _, officialClimb := attemptRideForClimb(t)
+
+ profile := rideanalysis.MergeClimbProfile(officialClimb, nil, official_climb.Aggregate{DistanceM: 1000}, official_climb.DefaultMatchPolicy(), climb.DefaultProfilePolicy())
+
+ assert.Empty(t, profile.Sections)
+ assert.Nil(t, profile.Route)
+}