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
|
package official_climb
import (
"math"
"github.com/jftuga/geodist"
"github.com/martinlehoux/biking_home/climb"
"github.com/martinlehoux/biking_home/geo"
)
// MatchedClimb is an official climb lying on a route, with the route indexes of its start and end: the segment the climb detector must not re-report.
type MatchedClimb struct {
OfficialClimb
StartIndex int
EndIndex int
}
// SegmentSampleBandRatio is the half-width of the band a candidate passage must land in, relative to the climb's sample.
const SegmentSampleBandRatio = 0.5
// MatchClimb matches an official climb against a route: endpoints within radiusM, start before end, and the passage inside the climb's sample band.
func MatchClimb(route climb.Route, official OfficialClimb, radiusM float64) (MatchedClimb, bool) {
matches := MatchClimbs(route, official, radiusM)
if len(matches) == 0 {
return MatchedClimb{}, false
}
return matches[0], true
}
// MatchClimbs returns every occurrence of an official climb on a route: each start cluster pairs with the first end cluster in its lap window, and pairings that contradict the recorded sample are dropped — what a loop ridden the other way produces.
func MatchClimbs(route climb.Route, official OfficialClimb, radiusM float64) []MatchedClimb {
if math.IsNaN(radiusM) || math.IsInf(radiusM, 0) || radiusM <= 0 {
return nil
}
if len(route.Coords) == 0 {
return nil
}
startClusters := clustersInRadius(route.Coords, official.StartCoord, radiusM)
endClusters := clustersInRadius(route.Coords, official.EndCoord, radiusM)
if len(startClusters) == 0 || len(endClusters) == 0 {
return nil
}
matches := make([]MatchedClimb, 0)
for i, start := range startClusters {
nextStartIdx := len(route.Coords)
if i+1 < len(startClusters) {
nextStartIdx = startClusters[i+1].Index
}
var matchedEnd *clusterCandidate
for j := range endClusters {
end := &endClusters[j]
if end.Index > start.Index && end.Index < nextStartIdx {
matchedEnd = end
break
}
}
if matchedEnd == nil {
continue
}
if !withinSampleBand(official.Sample, route.Sample(start.Index, matchedEnd.Index)) {
continue
}
matches = append(matches, MatchedClimb{
OfficialClimb: official,
StartIndex: start.Index,
EndIndex: matchedEnd.Index,
})
}
return matches
}
// withinSampleBand reports whether a candidate passage matches both axes of the climb's sample.
func withinSampleBand(sample, candidate climb.SegmentSample) bool {
return inSampleBand(candidate.DistanceM, sample.DistanceM) && inSampleBand(candidate.ElevationGainM, sample.ElevationGainM)
}
func inSampleBand(value, reference float64) bool {
return value >= reference*(1-SegmentSampleBandRatio) && value <= reference*(1+SegmentSampleBandRatio)
}
type clusterCandidate struct {
Index int
Distance float64
}
func clustersInRadius(route []geodist.Coord, target geodist.Coord, radiusM float64) []clusterCandidate {
candidates := make([]clusterCandidate, 0)
inCluster := false
bestIndex := 0
bestDistance := 0.0
for idx, coord := range route {
dist := geo.DistanceM(coord, target)
if dist <= radiusM {
if !inCluster {
inCluster = true
bestIndex = idx
bestDistance = dist
} else if dist < bestDistance {
bestIndex = idx
bestDistance = dist
}
} else if inCluster {
candidates = append(candidates, clusterCandidate{Index: bestIndex, Distance: bestDistance})
inCluster = false
}
}
if inCluster {
candidates = append(candidates, clusterCandidate{Index: bestIndex, Distance: bestDistance})
}
return candidates
}
|