aboutsummaryrefslogtreecommitdiff
path: root/mountain_pass/detection.go
blob: 79e55e47896ab39f563cd8b29e72a0404731119d (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
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
148
149
150
151
152
153
154
155
156
157
158
159
160
161

package mountain_pass

import (
	"database/sql"
	"math"

	"github.com/jftuga/geodist"
	"github.com/martinlehoux/biking_home/climb"
	"github.com/martinlehoux/biking_home/geo"
)

// MountainPass is a pass extracted from OpenStreetMap: a node tagged
// mountain_pass=yes (or legacy natural=mountain_pass) with a name and an
// elevation, as filtered by the loaders.
type MountainPass struct {
	Name      string
	Elevation int
	Coord     *geodist.Coord
}

type Crossing struct {
	Pass          MountainPass
	DistanceToM   float64
	RideDistanceM float64
	RideElevation float64
	ElevationDiff float64
}

func LoadMountainPasses(db *sql.DB) ([]MountainPass, error) {
	return loadMountainPasses(db, nil)
}

func LoadMountainPassesAroundRide(db *sql.DB, routeCoords []geodist.Coord, marginM float64) ([]MountainPass, error) {
	return loadMountainPasses(db, rideBounds(routeCoords, marginM))
}

type coordinateBounds struct {
	minLatitude  float64
	minLongitude float64
	maxLatitude  float64
	maxLongitude float64
}

func rideBounds(coords []geodist.Coord, marginM float64) *coordinateBounds {
	minLatitude, maxLatitude := coords[0].Lat, coords[0].Lat
	minLongitude, maxLongitude := coords[0].Lon, coords[0].Lon
	for index := 1; index < len(coords); index++ {
		coordinate := coords[index]
		minLatitude = math.Min(minLatitude, coordinate.Lat)
		maxLatitude = math.Max(maxLatitude, coordinate.Lat)
		minLongitude = math.Min(minLongitude, coordinate.Lon)
		maxLongitude = math.Max(maxLongitude, coordinate.Lon)
	}
	latitudeMargin := marginM / 111_320
	longitudeScale := math.Cos((minLatitude + maxLatitude) / 2 * math.Pi / 180)
	longitudeMargin := marginM / (111_320 * math.Max(longitudeScale, 0.01))
	return &coordinateBounds{
		minLatitude:  minLatitude - latitudeMargin,
		minLongitude: minLongitude - longitudeMargin,
		maxLatitude:  maxLatitude + latitudeMargin,
		maxLongitude: maxLongitude + longitudeMargin,
	}
}

func loadMountainPasses(db *sql.DB, bounds *coordinateBounds) ([]MountainPass, error) {
	query := `
		SELECT name, elevation, latitude, longitude
		FROM mountain_passes
		WHERE elevation IS NOT NULL
		  AND name IS NOT NULL AND name != ''`
	args := make([]any, 0, 4)
	if bounds != nil {
		query += `
		  AND latitude BETWEEN ? AND ?
		  AND longitude BETWEEN ? AND ?`
		args = append(args, bounds.minLatitude, bounds.maxLatitude, bounds.minLongitude, bounds.maxLongitude)
	}
	query += `
		ORDER BY elevation`
	rows, err := db.Query(query, args...)
	if err != nil {
		return nil, err
	}
	defer rows.Close()

	mountainPasses := make([]MountainPass, 0)
	for rows.Next() {
		var mountainPass MountainPass
		var latitude, longitude float64
		if err := rows.Scan(&mountainPass.Name, &mountainPass.Elevation, &latitude, &longitude); err != nil {
			return nil, err
		}
		mountainPass.Coord = &geodist.Coord{Lat: latitude, Lon: longitude}
		mountainPasses = append(mountainPasses, mountainPass)
	}
	return mountainPasses, rows.Err()
}

func DetectCrossings(coords []geodist.Coord, distances, elevations []float64, passes []MountainPass, radiusM, elevationToleranceM float64) []Crossing {
	crossings := make([]Crossing, 0)
	for _, mountainPass := range passes {
		if mountainPass.Coord == nil {
			continue
		}
		crossing, found := nearestCrossing(coords, distances, elevations, mountainPass)
		if found && crossing.DistanceToM <= radiusM && crossing.ElevationDiff <= elevationToleranceM {
			crossings = append(crossings, crossing)
		}
	}
	return crossings
}

// MatchClimb returns the pass whose coordinates lie within radiusM of the
// climb's highest point and whose elevation is within elevationToleranceM of
// that point's elevation, nearest first. Found is false when no pass matches.
func MatchClimb(segment climb.Climb, passes []MountainPass, radiusM, elevationToleranceM float64) (MountainPass, bool) {
	topIndex, topElevation := segment.Top()
	topCoord := segment.Coords[topIndex]
	var best MountainPass
	bestDistanceM := radiusM
	found := false
	for _, mountainPass := range passes {
		if mountainPass.Coord == nil {
			continue
		}
		distanceKm, _ := geodist.HaversineDistance(topCoord, *mountainPass.Coord)
		distanceM := distanceKm * 1000
		if distanceM > bestDistanceM {
			continue
		}
		elevationDiff := absFloat64(topElevation - float64(mountainPass.Elevation))
		if elevationDiff > elevationToleranceM {
			continue
		}
		best = mountainPass
		bestDistanceM = distanceM
		found = true
	}
	return best, found
}

func nearestCrossing(coords []geodist.Coord, distances, elevations []float64, mountainPass MountainPass) (Crossing, bool) {
	index, distanceM := geo.Nearest(coords, *mountainPass.Coord)
	if math.IsInf(distanceM, 1) {
		return Crossing{Pass: mountainPass}, false
	}
	return Crossing{
		Pass:          mountainPass,
		DistanceToM:   distanceM,
		RideDistanceM: distances[index],
		RideElevation: elevations[index],
		ElevationDiff: absFloat64(elevations[index] - float64(mountainPass.Elevation)),
	}, true
}

func absFloat64(value float64) float64 {
	if value < 0 {
		return -value
	}
	return value
}