aboutsummaryrefslogtreecommitdiff
path: root/rideanalysis/profile.go
blob: d9fd94f91d593800a6969a37b4a1c4e246de59ca (plain) (blame)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
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
}