summaryrefslogtreecommitdiff
path: root/mountain_pass/detection.go
diff options
context:
space:
mode:
authorMartin Kagamino Lehoux <martin@lehoux.net>2026-08-03 08:25:07 +0200
committerMartin Kagamino Lehoux <martin@lehoux.net>2026-08-03 08:25:07 +0200
commit9707f9ad09db935af7d06b5de08bc52de13ba6a7 (patch)
tree34bcd01e5eda302a8730bf0817d5dd7e82186359 /mountain_pass/detection.go
parentf06a10982ab9d5aaa7d31cea31964431fa75f253 (diff)
feat: Detect mountain pass crossings, name climbs after passes, render ride charts
- osmpass: extract mountain_pass=yes nodes from an OSM PBF, enrich centcols passes with OSM coordinates, resumable -fetch-osm download - mountain_pass: DetectCrossings for a ride, MatchClimb to name a climb after the pass it tops - ride: expose Points(), add Climb.Top() and Climb.Name - chart: -chart renders elevation profile with climb bands and pass markers - commands: -fetch-osm, -extract-osm, -import-cached, -enrich
Diffstat (limited to 'mountain_pass/detection.go')
-rw-r--r--mountain_pass/detection.go115
1 files changed, 115 insertions, 0 deletions
diff --git a/mountain_pass/detection.go b/mountain_pass/detection.go
new file mode 100644
index 0000000..e30fddc
--- /dev/null
+++ b/mountain_pass/detection.go
@@ -0,0 +1,115 @@
+package mountain_pass
+
+import (
+ "database/sql"
+ "fmt"
+
+ "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) {
+ rows, err := db.Query(`
+ SELECT external_id, name, country_code, department_code, elevation, latitude, longitude
+ FROM mountain_passes
+ ORDER BY elevation
+ `)
+ 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) {
+ top := climb.Top()
+ var best MountainPass
+ bestDistanceM := radiusM
+ found := false
+ for _, mountainPass := range passes {
+ if mountainPass.Coord == nil {
+ continue
+ }
+ distanceKm, _ := geodist.HaversineDistance(top.Coord, *mountainPass.Coord)
+ distanceM := distanceKm * 1000
+ if distanceM > bestDistanceM {
+ continue
+ }
+ elevationDiff := absFloat64(top.ElevationM - 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 _, point := range ride.Points() {
+ distanceKm, _ := geodist.HaversineDistance(point.Coord, *mountainPass.Coord)
+ distanceM := distanceKm * 1000
+ if distanceM < best.DistanceToM {
+ best.DistanceToM = distanceM
+ best.RideDistanceM = point.DistanceM
+ best.RideElevation = point.ElevationM
+ best.ElevationDiff = absFloat64(point.ElevationM - 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)
+}