aboutsummaryrefslogtreecommitdiff
path: root/mountain_pass
diff options
context:
space:
mode:
Diffstat (limited to 'mountain_pass')
-rw-r--r--mountain_pass/detection.go11
-rw-r--r--mountain_pass/extract.go172
-rw-r--r--mountain_pass/extract_test.go43
3 files changed, 196 insertions, 30 deletions
diff --git a/mountain_pass/detection.go b/mountain_pass/detection.go
index 79e55e4..a79377f 100644
--- a/mountain_pass/detection.go
+++ b/mountain_pass/detection.go
@@ -11,11 +11,13 @@ import (
// 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.
+// elevation, as filtered by the loaders. Category classifies bike suitability
+// (road, track, path, hiking, unknown) derived from containing way highways.
type MountainPass struct {
Name string
Elevation int
Coord *geodist.Coord
+ Category string
}
type Crossing struct {
@@ -64,7 +66,7 @@ func rideBounds(coords []geodist.Coord, marginM float64) *coordinateBounds {
func loadMountainPasses(db *sql.DB, bounds *coordinateBounds) ([]MountainPass, error) {
query := `
- SELECT name, elevation, latitude, longitude
+ SELECT name, elevation, latitude, longitude, COALESCE(category,'unknown')
FROM mountain_passes
WHERE elevation IS NOT NULL
AND name IS NOT NULL AND name != ''`
@@ -87,10 +89,13 @@ func loadMountainPasses(db *sql.DB, bounds *coordinateBounds) ([]MountainPass, e
for rows.Next() {
var mountainPass MountainPass
var latitude, longitude float64
- if err := rows.Scan(&mountainPass.Name, &mountainPass.Elevation, &latitude, &longitude); err != nil {
+ if err := rows.Scan(&mountainPass.Name, &mountainPass.Elevation, &latitude, &longitude, &mountainPass.Category); err != nil {
return nil, err
}
mountainPass.Coord = &geodist.Coord{Lat: latitude, Lon: longitude}
+ if mountainPass.Category == "" {
+ mountainPass.Category = CategoryUnknown
+ }
mountainPasses = append(mountainPasses, mountainPass)
}
return mountainPasses, rows.Err()
diff --git a/mountain_pass/extract.go b/mountain_pass/extract.go
index 9643603..ccf8d06 100644
--- a/mountain_pass/extract.go
+++ b/mountain_pass/extract.go
@@ -15,27 +15,34 @@ import (
)
const insertStatement = `
- INSERT INTO mountain_passes (osm_id, name, elevation, latitude, longitude)
- VALUES (?, ?, ?, ?, ?)
+ INSERT INTO mountain_passes (osm_id, name, elevation, latitude, longitude, category)
+ VALUES (?, ?, ?, ?, ?, ?)
ON CONFLICT(osm_id) DO UPDATE SET
name = excluded.name,
elevation = excluded.elevation,
latitude = excluded.latitude,
- longitude = excluded.longitude
+ longitude = excluded.longitude,
+ category = excluded.category
`
+// PassCategory classifies a pass for bike suitability: road = paved road,
+// track = unpaved vehicle track, path = hiking trail, hiking = rock/climbing,
+// unknown = not on a highway.
+const (
+ CategoryRoad = "road"
+ CategoryTrack = "track"
+ CategoryPath = "path"
+ CategoryHiking = "hiking"
+ CategoryUnknown = "unknown"
+)
+
// ExtractMountainPasses scans pbfPath for mountain pass nodes and upserts them
// into the database. An optional reporter is invoked as scanning progresses
// with bytes scanned and the total file size, so callers (e.g. the CLI) can
-// render progress.
+// render progress. Category is derived from the highways of ways that contain
+// the pass node (road > track > path) plus node tags (rock/climbing => hiking).
func ExtractMountainPasses(ctx context.Context, pbfPath string, db *sql.DB, reporters ...func(current, total int64)) (int, error) {
- file, err := os.Open(pbfPath)
- if err != nil {
- return 0, fmt.Errorf("failed to open pbf file: %w", err)
- }
- defer file.Close()
-
- info, err := file.Stat()
+ info, err := os.Stat(pbfPath)
if err != nil {
return 0, fmt.Errorf("failed to stat pbf file: %w", err)
}
@@ -44,12 +51,91 @@ func ExtractMountainPasses(ctx context.Context, pbfPath string, db *sql.DB, repo
if len(reporters) > 0 {
report = reporters[0]
}
+
+ // Phase 1: collect pass nodes.
+ type cachedPass struct {
+ id osm.NodeID
+ name string
+ elevation any
+ lat, lon float64
+ tags osm.Tags
+ }
+ file, err := os.Open(pbfPath)
+ if err != nil {
+ return 0, fmt.Errorf("failed to open pbf file: %w", err)
+ }
reader := &countingReader{r: file}
scanner := osmpbf.New(ctx, reader, 4)
scanner.SkipWays = true
scanner.SkipRelations = true
scanner.FilterNode = isMountainPass
+ cached := make([]cachedPass, 0, 4096)
+ passSet := make(map[osm.NodeID]int, 4096) // node ID -> index in cached
+ for scanner.Scan() {
+ report(reader.n, total)
+ node, ok := scanner.Object().(*osm.Node)
+ if !ok {
+ continue
+ }
+ elevation, hasElevation := parseElevation(node.Tags.Find("ele"))
+ var elevationValue any
+ if hasElevation {
+ elevationValue = elevation
+ }
+ idx := len(cached)
+ cached = append(cached, cachedPass{
+ id: node.ID,
+ name: node.Tags.Find("name"),
+ elevation: elevationValue,
+ lat: float64(node.Lat),
+ lon: float64(node.Lon),
+ tags: node.Tags,
+ })
+ passSet[node.ID] = idx
+ }
+ if err := scanner.Err(); err != nil && err != io.EOF {
+ file.Close()
+ return 0, fmt.Errorf("failed to scan pbf nodes: %w", err)
+ }
+ file.Close()
+
+ // Phase 2: scan ways to collect highways per pass.
+ highwaysByPass := make(map[osm.NodeID][]string, len(cached))
+ file2, err := os.Open(pbfPath)
+ if err != nil {
+ return 0, fmt.Errorf("failed to reopen pbf for ways: %w", err)
+ }
+ defer file2.Close()
+ reader2 := &countingReader{r: file2}
+ scanner2 := osmpbf.New(ctx, reader2, 4)
+ scanner2.SkipNodes = true
+ scanner2.SkipRelations = true
+ for scanner2.Scan() {
+ // Report second phase as second half of total for progress continuity.
+ // We offset by total to avoid going backwards; callers use ratio anyway.
+ report(reader2.n, total)
+ way, ok := scanner2.Object().(*osm.Way)
+ if !ok {
+ continue
+ }
+ highway := way.Tags.Find("highway")
+ // Keep even empty highway to detect non-highway ways? Only track highways.
+ // We store only ways that actually represent a highway for categorisation;
+ // non-highway ways are ignored (they keep pass as unknown).
+ if highway == "" {
+ continue
+ }
+ for _, nid := range way.Nodes {
+ if _, ok := passSet[nid.ID]; ok {
+ highwaysByPass[nid.ID] = append(highwaysByPass[nid.ID], highway)
+ }
+ }
+ }
+ if err := scanner2.Err(); err != nil && err != io.EOF {
+ return 0, fmt.Errorf("failed to scan pbf ways: %w", err)
+ }
+
tx, err := db.BeginTx(ctx, nil)
if err != nil {
return 0, fmt.Errorf("failed to begin transaction: %w", err)
@@ -63,25 +149,14 @@ func ExtractMountainPasses(ctx context.Context, pbfPath string, db *sql.DB, repo
defer stmt.Close()
count := 0
- for scanner.Scan() {
- report(reader.n, total)
- 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)
+ for _, p := range cached {
+ highways := highwaysByPass[p.id]
+ category := categorizePass(p.tags, highways)
+ if _, err := stmt.ExecContext(ctx, p.id, p.name, p.elevation, p.lat, p.lon, category); err != nil {
+ return count, fmt.Errorf("failed to insert osm pass %d: %w", p.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)
}
@@ -89,6 +164,49 @@ func ExtractMountainPasses(ctx context.Context, pbfPath string, db *sql.DB, repo
return count, nil
}
+func categorizePass(tags osm.Tags, highways []string) string {
+ natural := tags.Find("natural")
+ sport := tags.Find("sport")
+ isHikingNode := natural == "rock" || sport == "climbing"
+ for _, h := range highways {
+ if isRoadHighway(h) {
+ return CategoryRoad
+ }
+ }
+ if isHikingNode {
+ return CategoryHiking
+ }
+ for _, h := range highways {
+ if h == "track" {
+ return CategoryTrack
+ }
+ }
+ for _, h := range highways {
+ if isPathHighway(h) {
+ return CategoryPath
+ }
+ }
+ return CategoryUnknown
+}
+
+func isRoadHighway(highway string) bool {
+ switch highway {
+ case "motorway", "motorway_link", "trunk", "trunk_link", "primary", "primary_link", "secondary", "secondary_link", "tertiary", "tertiary_link", "unclassified", "residential", "service", "living_street", "road", "pedestrian":
+ return true
+ default:
+ return false
+ }
+}
+
+func isPathHighway(highway string) bool {
+ switch highway {
+ case "path", "footway", "cycleway", "bridleway", "steps", "via_ferrata":
+ return true
+ default:
+ return false
+ }
+}
+
// countingReader tracks the number of bytes read from an underlying reader,
// used to report scan progress against the PBF file size.
type countingReader struct {
diff --git a/mountain_pass/extract_test.go b/mountain_pass/extract_test.go
index 7eae33b..952ba08 100644
--- a/mountain_pass/extract_test.go
+++ b/mountain_pass/extract_test.go
@@ -68,3 +68,46 @@ func TestCountingReaderTracksBytes(t *testing.T) {
}
assert.Equal(t, int64(len(content)), reader.n)
}
+
+func TestCategorizePass(t *testing.T) {
+ cases := []struct {
+ name string
+ tags map[string]string
+ highways []string
+ wantCategory string
+ }{
+ {"road secondary", map[string]string{"natural": "saddle"}, []string{"secondary"}, CategoryRoad},
+ {"road wins over path", map[string]string{"natural": "saddle"}, []string{"path", "secondary", "track"}, CategoryRoad},
+ {"track", map[string]string{"natural": "saddle"}, []string{"track"}, CategoryTrack},
+ {"path", map[string]string{"natural": "saddle"}, []string{"path"}, CategoryPath},
+ {"hiking rock path", map[string]string{"natural": "rock", "sport": "climbing"}, []string{"path"}, CategoryHiking},
+ {"hiking rock no way", map[string]string{"natural": "rock"}, nil, CategoryHiking},
+ {"unknown no way", map[string]string{"natural": "saddle"}, nil, CategoryUnknown},
+ {"via_ferrata is path", map[string]string{"natural": "saddle"}, []string{"via_ferrata"}, CategoryPath},
+ {"unknown other highway", map[string]string{"natural": "saddle"}, []string{"construction"}, CategoryUnknown},
+ {"hiking rock but road present is road", map[string]string{"natural": "rock", "sport": "climbing"}, []string{"secondary"}, CategoryRoad},
+ }
+ for _, c := range cases {
+ tags := osm.Tags{}
+ for k, v := range c.tags {
+ tags = append(tags, osm.Tag{Key: k, Value: v})
+ }
+ cat := categorizePass(tags, c.highways)
+ assert.Equal(t, c.wantCategory, cat, c.name)
+ }
+}
+
+func TestIsRoadHighway(t *testing.T) {
+ assert.True(t, isRoadHighway("secondary"))
+ assert.True(t, isRoadHighway("primary"))
+ assert.True(t, isRoadHighway("tertiary"))
+ assert.True(t, isRoadHighway("residential"))
+ assert.False(t, isRoadHighway("track"))
+ assert.False(t, isRoadHighway("path"))
+}
+
+func TestIsPathHighway(t *testing.T) {
+ assert.True(t, isPathHighway("path"))
+ assert.True(t, isPathHighway("footway"))
+ assert.False(t, isPathHighway("secondary"))
+}