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 }