From 9707f9ad09db935af7d06b5de08bc52de13ba6a7 Mon Sep 17 00:00:00 2001 From: Martin Kagamino Lehoux Date: Mon, 3 Aug 2026 08:25:07 +0200 Subject: 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 --- osmpass/extract.go | 98 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 98 insertions(+) create mode 100644 osmpass/extract.go (limited to 'osmpass/extract.go') diff --git a/osmpass/extract.go b/osmpass/extract.go new file mode 100644 index 0000000..cda6890 --- /dev/null +++ b/osmpass/extract.go @@ -0,0 +1,98 @@ +package osmpass + +import ( + "context" + "database/sql" + "fmt" + "io" + "log/slog" + "os" + "strconv" + "strings" + + "github.com/paulmach/osm" + "github.com/paulmach/osm/osmpbf" +) + +const insertStatement = ` + INSERT INTO osm_passes (osm_id, name, elevation, latitude, longitude) + VALUES (?, ?, ?, ?, ?) + ON CONFLICT(osm_id) DO UPDATE SET + name = excluded.name, + elevation = excluded.elevation, + latitude = excluded.latitude, + longitude = excluded.longitude +` + +func ExtractMountainPasses(ctx context.Context, pbfPath string, db *sql.DB) (int, error) { + file, err := os.Open(pbfPath) + if err != nil { + return 0, fmt.Errorf("failed to open pbf file: %w", err) + } + defer file.Close() + + scanner := osmpbf.New(ctx, file, 4) + scanner.SkipWays = true + scanner.SkipRelations = true + scanner.FilterNode = isMountainPass + + tx, err := db.BeginTx(ctx, nil) + if err != nil { + return 0, fmt.Errorf("failed to begin transaction: %w", err) + } + defer tx.Rollback() + + stmt, err := tx.PrepareContext(ctx, insertStatement) + if err != nil { + return 0, fmt.Errorf("failed to prepare insert: %w", err) + } + defer stmt.Close() + + count := 0 + for scanner.Scan() { + node, ok := scanner.Object().(*osm.Node) + if !ok { + continue + } + elevation, hasElevation := parseElevation(node.Tags.Find("ele")) + var elevationValue any + if hasElevation { + elevationValue = elevation + } + if _, err := stmt.ExecContext(ctx, node.ID, node.Tags.Find("name"), elevationValue, node.Lat, node.Lon); err != nil { + return count, fmt.Errorf("failed to insert osm pass %d: %w", node.ID, err) + } + count++ + } + if err := scanner.Err(); err != nil && err != io.EOF { + return count, fmt.Errorf("failed to scan pbf: %w", err) + } + if err := tx.Commit(); err != nil { + return count, fmt.Errorf("failed to commit transaction: %w", err) + } + slog.Info("Extracted mountain passes", "count", count, "source", pbfPath) + return count, nil +} + +func isMountainPass(node *osm.Node) bool { + if node.Tags.Find("mountain_pass") == "yes" { + return true + } + return node.Tags.Find("natural") == "mountain_pass" +} + +func parseElevation(ele string) (int, bool) { + ele = strings.TrimSpace(ele) + if ele == "" { + return 0, false + } + for _, candidate := range strings.Fields(ele) { + if value, err := strconv.Atoi(candidate); err == nil { + return value, true + } + if value, err := strconv.ParseFloat(candidate, 64); err == nil { + return int(value), true + } + } + return 0, false +} -- cgit v1.2.3