diff options
Diffstat (limited to 'rideanalysis')
| -rw-r--r-- | rideanalysis/profile.go | 72 | ||||
| -rw-r--r-- | rideanalysis/profile_test.go | 56 |
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) +} |
