summaryrefslogtreecommitdiff
path: root/mountain_pass/detection.go
blob: 4c54493cefd736700520bd11e045f1bc09328f70 (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
162
package mountain_pass

import (
	"database/sql"
	"fmt"
	"math"

	"github.com/jftuga/geodist"
	"github.com/martinlehoux/biking_home/ride"
)

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, route ride.Ride, marginM float64) ([]MountainPass, error) {
	return loadMountainPasses(db, rideBounds(route, marginM))
}

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

func rideBounds(route ride.Ride, marginM float64) *coordinateBounds {
	minLatitude, maxLatitude := route.Coord(0).Lat, route.Coord(0).Lat
	minLongitude, maxLongitude := route.Coord(0).Lon, route.Coord(0).Lon
	for index := 1; index < route.Len(); index++ {
		coordinate := route.Coord(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 external_id, name, country_code, department_code, elevation, latitude, longitude
		FROM mountain_passes`
	args := make([]any, 0, 4)
	if bounds != nil {
		query += `
		WHERE latitude IS NOT NULL AND longitude IS NOT NULL
		  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 sql.NullFloat64
		if err := rows.Scan(&mountainPass.ExternalID, &mountainPass.Name, &mountainPass.CountryCode, &mountainPass.DepartmentCode, &mountainPass.Elevation, &latitude, &longitude); err != nil {
			return nil, err
		}
		if latitude.Valid && longitude.Valid {
			mountainPass.Coord = &geodist.Coord{Lat: latitude.Float64, Lon: longitude.Float64}
		}
		mountainPasses = append(mountainPasses, mountainPass)
	}
	return mountainPasses, rows.Err()
}

func DetectCrossings(ride ride.Ride, passes []MountainPass, radiusM, elevationToleranceM float64) []Crossing {
	crossings := make([]Crossing, 0)
	for _, mountainPass := range passes {
		if mountainPass.Coord == nil {
			continue
		}
		crossing, found := nearestCrossing(ride, 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(climb ride.Climb, passes []MountainPass, radiusM, elevationToleranceM float64) (MountainPass, bool) {
	topCoord := climb.TopCoord()
	topElevation := climb.TopElevationM()
	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(ride ride.Ride, mountainPass MountainPass) (Crossing, bool) {
	best := Crossing{Pass: mountainPass, DistanceToM: 1e18}
	for i := 0; i < ride.Len(); i++ {
		distanceKm, _ := geodist.HaversineDistance(ride.Coord(i), *mountainPass.Coord)
		distanceM := distanceKm * 1000
		if distanceM < best.DistanceToM {
			best.DistanceToM = distanceM
			best.RideDistanceM = ride.DistanceM(i)
			best.RideElevation = ride.ElevationM(i)
			best.ElevationDiff = absFloat64(ride.ElevationM(i) - float64(mountainPass.Elevation))
		}
	}
	if best.DistanceToM > 1e17 {
		return best, false
	}
	return best, true
}

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

func (crossing Crossing) String() string {
	return fmt.Sprintf("%s (%dm) at %.1fkm, %.0fm away, Δelev %.0fm",
		crossing.Pass.Name, crossing.Pass.Elevation, crossing.RideDistanceM/1000, crossing.DistanceToM, crossing.ElevationDiff)
}