aboutsummaryrefslogtreecommitdiff
path: root/official_climb/matcher.go
blob: 657d2ef30300f80ea20c4d232e7a059de22fe3fb (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
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
}