aboutsummaryrefslogtreecommitdiff
path: root/mountain_pass/extract.go
blob: 9643603c248a15be240182ca6b0f7178eca2b3aa (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

package mountain_pass

import (
	"context"
	"database/sql"
	"fmt"
	"io"
	"log/slog"
	"os"
	"strconv"
	"strings"

	"github.com/paulmach/osm"
	"github.com/paulmach/osm/osmpbf"
)

const insertStatement = `
	INSERT INTO mountain_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
`

// 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.
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()
	if err != nil {
		return 0, fmt.Errorf("failed to stat pbf file: %w", err)
	}
	total := info.Size()
	report := func(int64, int64) {}
	if len(reporters) > 0 {
		report = reporters[0]
	}
	reader := &countingReader{r: file}
	scanner := osmpbf.New(ctx, reader, 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() {
		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)
		}
		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
}

// countingReader tracks the number of bytes read from an underlying reader,
// used to report scan progress against the PBF file size.
type countingReader struct {
	r io.Reader
	n int64
}

func (c *countingReader) Read(p []byte) (int, error) {
	n, err := c.r.Read(p)
	c.n += int64(n)
	return n, err
}

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.FieldsSeq(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
}