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
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
|
package official_climb
import (
"sort"
"time"
"github.com/martinlehoux/biking_home/climb"
)
const profileSteps = 50
// Aggregate is the climb-level summary derived from its recorded attempts.
type Aggregate struct {
Attempts int
DistanceM float64
ElevationGainM float64
SlopePercent float64
Cotacol float64
Category climb.Category
BestDuration time.Duration
HasBest bool
}
// AggregateAttempts summarizes a climb from its attempts: every scalar is the
// median across attempts so one noisy ride cannot define the climb.
func AggregateAttempts(attempts []Attempt) Aggregate {
if len(attempts) == 0 {
return Aggregate{}
}
distances := make([]float64, len(attempts))
gains := make([]float64, len(attempts))
slopes := make([]float64, len(attempts))
cotacols := make([]float64, len(attempts))
aggregate := Aggregate{Attempts: len(attempts)}
for i, attempt := range attempts {
distances[i] = attempt.DistanceM
gains[i] = attempt.ElevationGainM
slopes[i] = attempt.SlopePercent
cotacols[i] = attempt.Cotacol
duration := attempt.EndedAt.Sub(attempt.StartedAt)
if duration > 0 && (!aggregate.HasBest || duration < aggregate.BestDuration) {
aggregate.BestDuration = duration
aggregate.HasBest = true
}
}
aggregate.DistanceM = Median(distances)
aggregate.ElevationGainM = Median(gains)
aggregate.SlopePercent = Median(slopes)
aggregate.Cotacol = Median(cotacols)
aggregate.Category = climb.CategoryOf(aggregate.Cotacol)
return aggregate
}
// Median returns the median of values; for an even count it averages the two
// middle values.
func Median(values []float64) float64 {
if len(values) == 0 {
return 0
}
sorted := make([]float64, len(values))
copy(sorted, values)
sort.Float64s(sorted)
middle := len(sorted) / 2
if len(sorted)%2 == 1 {
return sorted[middle]
}
return (sorted[middle-1] + sorted[middle]) / 2
}
// normalizedSample locates one normalized step inside a segment: the index of
// the recorded point preceding the target distance and the interpolation
// fraction toward the next point.
type normalizedSample struct {
index int
fraction float64
}
// normalizedSamples locates profileSteps equally spaced fractions of the
// segment length, interpolating between recorded points. Distances are
// relative: segments of different lengths stay comparable.
func normalizedSamples(distances []float64) []normalizedSample {
if len(distances) < 2 {
return nil
}
start, end := distances[0], distances[len(distances)-1]
span := end - start
if span <= 0 {
return nil
}
samples := make([]normalizedSample, profileSteps)
i := 0
for step := range profileSteps {
target := start + span*float64(step)/float64(profileSteps-1)
for i < len(distances)-2 && distances[i+1] < target {
i++
}
segmentSpan := distances[i+1] - distances[i]
fraction := 0.0
if segmentSpan > 0 {
fraction = (target - distances[i]) / segmentSpan
}
samples[step] = normalizedSample{index: i, fraction: fraction}
}
return samples
}
// NormalizeProfile samples a climb segment's elevation at profileSteps
// equally spaced fractions of the segment length, interpolating between
// recorded points.
func NormalizeProfile(segment climb.Climb) []float64 {
samples := normalizedSamples(segment.Distances)
if samples == nil {
return nil
}
elevations := make([]float64, len(samples))
for step, sample := range samples {
elevations[step] = sampleElevation(segment.Distances, segment.Elevations, sample)
}
return elevations
}
func sampleElevation(distances, elevations []float64, sample normalizedSample) float64 {
i := sample.index
if i+1 >= len(distances) {
return elevations[len(elevations)-1]
}
return elevations[i] + sample.fraction*(elevations[i+1]-elevations[i])
}
// MedianProfile merges per-attempt normalized elevation samples into one
// canonical profile: the median elevation at every step.
func MedianProfile(samples [][]float64) []float64 {
median := make([]float64, 0, profileSteps)
for step := range profileSteps {
values := make([]float64, 0, len(samples))
for _, sample := range samples {
if step < len(sample) {
values = append(values, sample[step])
}
}
if len(values) == 0 {
continue
}
median = append(median, Median(values))
}
return median
}
|