summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--.gitignore2
-rw-r--r--README.md19
-rw-r--r--chart.go81
-rw-r--r--db/migrations/20260802090000_osm_passes.sql17
-rw-r--r--db/schema.sql11
-rw-r--r--go.mod16
-rw-r--r--go.sum41
-rw-r--r--main.go134
-rw-r--r--mountain_pass/detection.go115
-rw-r--r--mountain_pass/detection_test.go109
-rw-r--r--mountain_pass/downloader.go61
-rw-r--r--mountain_pass/downloader_test.go6
-rw-r--r--osmpass/enrich.go240
-rw-r--r--osmpass/enrich_test.go149
-rw-r--r--osmpass/extract.go98
-rw-r--r--osmpass/extract_test.go49
-rw-r--r--osmpass/fetch.go98
-rw-r--r--ride/climb.go19
-rw-r--r--ride/ride.go4
19 files changed, 1214 insertions, 55 deletions
diff --git a/.gitignore b/.gitignore
index 8a49c36..25407bf 100644
--- a/.gitignore
+++ b/.gitignore
@@ -2,3 +2,5 @@
*.db
*.png
debug_department_*.csv
+france-latest.osm.pbf
+biking_home
diff --git a/README.md b/README.md
index 97c022e..06af5d3 100644
--- a/README.md
+++ b/README.md
@@ -9,6 +9,7 @@ A Go toolkit for analyzing cycling rides from GPX exports: parse rides, detect c
- **Difficulty score (Cotacol)** — scores any ride or climb by splitting it into 100 m segments and summing `0.1 km × slope²` per segment
- **Similar-climb matching** — finds the same climb across rides by matching start/end coordinates, so times can be compared
- **Mountain pass download** — imports French mountain passes from centcols.org into a SQLite database
+- **Pass crossing detection** — enriches passes with OSM coordinates, flags which passes a ride crosses, and names each climb after the pass it tops (e.g. "Col de Castellaras")
- **Plots** — renders elevation and score-profile charts as PNG
## Getting started
@@ -29,6 +30,18 @@ go build -o biking_home .
# Run the climb-similarity and difficulty-score demo on the example rides
./biking_home -demo
+# Import cached department CSVs (debug_department_06.csv, 13.csv) into the DB
+./biking_home -import-cached
+
+# Extract mountain passes from a France OSM PBF into the DB
+./biking_home -extract-osm /path/to/france-latest.osm.pbf
+
+# Backfill OSM coordinates onto passes (needs -extract-osm run first)
+./biking_home -enrich
+
+# Render an elevation chart with climb highlights and pass markers
+./biking_home -chart examples/2023-06-17.AlpesVerdonTour.gpx
+
# Profile the demo with Go's CPU profiler
./biking_home -demo -cpuprofile /tmp/cpu.prof
```
@@ -39,6 +52,10 @@ go build -o biking_home .
| --- | --- | --- |
| `-download` | Download mountain passes into the database | `false` |
| `-resume` | Skip departments already cached on disk | `false` |
+| `-import-cached` | Import cached department CSVs into the database | `false` |
+| `-extract-osm` | Extract mountain passes from a France OSM PBF | `""` |
+| `-enrich` | Backfill OSM coordinates onto passes | `false` |
+| `-chart` | Render an elevation chart (climbs + passes) for a GPX file | `""` |
| `-demo` | Run the climb-similarity demo | `false` |
| `-cpuprofile` | Write a CPU profile to file | `""` |
@@ -47,6 +64,7 @@ go build -o biking_home .
- Go 1.23; SQLite via `mattn/go-sqlite3`; charts via `gonum.org/v1/plot`
- `ride` — GPX parsing, climb detection, difficulty scores (KOM + Cotacol), similarity index
- `mountain_pass` — centcols.org department CSV download into SQLite, with disk caching and retries
+- `osmpass` — OSM PBF extraction (`mountain_pass=yes` nodes) and pass coordinate enrichment
- **Notable choices** — the difficulty score follows the Cotacol method: the ride is split into fixed 100 m segments and each scores `distance_km × slope²`, so steep sections weigh exponentially more than long flat ones
## Development
@@ -60,7 +78,6 @@ go test ./...
- Compute estimated power
- Export data from Strava / Garmin (GPX, TCX, FIT)
-- Auto-detect when a mountain pass is crossed
- Plot speed and slope per segment, colored by heart rate
- Persist the chosen climb variant across activities
- Handle historical data
diff --git a/chart.go b/chart.go
new file mode 100644
index 0000000..063b495
--- /dev/null
+++ b/chart.go
@@ -0,0 +1,81 @@
+package main
+
+import (
+ "fmt"
+ "image/color"
+
+ "github.com/martinlehoux/biking_home/mountain_pass"
+ "github.com/martinlehoux/biking_home/ride"
+ "github.com/martinlehoux/kagamigo/kcore"
+ "gonum.org/v1/plot"
+ "gonum.org/v1/plot/plotter"
+ "gonum.org/v1/plot/vg"
+)
+
+func renderChart(r ride.Ride, climbs []ride.Climb, crossings []mountain_pass.Crossing, output string) {
+ p := plot.New()
+ p.Title.Text = "Ride profile"
+ p.X.Label.Text = "Distance (km)"
+ p.Y.Label.Text = "Elevation (m)"
+
+ maxElev := 0.0
+ elevation := make(plotter.XYs, len(r.Points()))
+ for i, point := range r.Points() {
+ elevation[i].X = point.DistanceM / 1000
+ elevation[i].Y = point.ElevationM
+ if point.ElevationM > maxElev {
+ maxElev = point.ElevationM
+ }
+ }
+
+ for _, climb := range climbs {
+ x0 := climb.Start().DistanceM / 1000
+ x1 := climb.End().DistanceM / 1000
+ fill, err := plotter.NewPolygon(plotter.XYs{
+ {X: x0, Y: 0},
+ {X: x1, Y: 0},
+ {X: x1, Y: maxElev},
+ {X: x0, Y: maxElev},
+ })
+ kcore.Expect(err, "failed to create climb band")
+ fill.Color = color.RGBA{R: 230, G: 160, B: 0, A: 70}
+ fill.LineStyle.Width = 0
+ p.Add(fill)
+ p.Add(plotLabels(plotter.XY{X: (x0 + x1) / 2, Y: climb.Top().ElevationM * 1.01}, climbLabel(climb)))
+ }
+
+ for _, crossing := range crossings {
+ x := crossing.RideDistanceM / 1000
+ line, err := plotter.NewLine(plotter.XYs{
+ {X: x, Y: 0},
+ {X: x, Y: maxElev},
+ })
+ kcore.Expect(err, "failed to create pass marker")
+ line.LineStyle.Dashes = []vg.Length{vg.Points(4), vg.Points(2)}
+ p.Add(line)
+ p.Add(plotLabels(plotter.XY{X: x, Y: maxElev * 0.96},
+ fmt.Sprintf("%s %dm", crossing.Pass.Name, crossing.Pass.Elevation)))
+ }
+
+ elevLine, err := plotter.NewLine(elevation)
+ kcore.Expect(err, "failed to create elevation line")
+ p.Add(elevLine)
+
+ kcore.Expect(p.Save(10*vg.Inch, 4*vg.Inch, output), "failed to save chart")
+}
+
+func climbLabel(climb ride.Climb) string {
+ if climb.Name != "" {
+ return climb.Name
+ }
+ return fmt.Sprintf("%s %d", ride.Category(climb.Score()), int(climb.Score()))
+}
+
+func plotLabels(xy plotter.XY, label string) *plotter.Labels {
+ labels, err := plotter.NewLabels(plotter.XYLabels{
+ XYs: plotter.XYs{xy},
+ Labels: []string{label},
+ })
+ kcore.Expect(err, "failed to create labels")
+ return labels
+}
diff --git a/db/migrations/20260802090000_osm_passes.sql b/db/migrations/20260802090000_osm_passes.sql
new file mode 100644
index 0000000..873e80a
--- /dev/null
+++ b/db/migrations/20260802090000_osm_passes.sql
@@ -0,0 +1,17 @@
+-- migrate:up
+create table osm_passes (
+ id integer primary key,
+ osm_id integer unique not null,
+ name text,
+ elevation integer,
+ latitude real not null,
+ longitude real not null
+);
+
+alter table mountain_passes add column latitude real;
+alter table mountain_passes add column longitude real;
+
+-- migrate:down
+alter table mountain_passes drop column latitude;
+alter table mountain_passes drop column longitude;
+drop table osm_passes;
diff --git a/db/schema.sql b/db/schema.sql
index e6796c8..398bfea 100644
--- a/db/schema.sql
+++ b/db/schema.sql
@@ -6,7 +6,16 @@ CREATE TABLE mountain_passes (
country_code text not null,
department_code text not null,
elevation integer not null
+, latitude real, longitude real);
+CREATE TABLE osm_passes (
+ id integer primary key,
+ osm_id integer unique not null,
+ name text,
+ elevation integer,
+ latitude real not null,
+ longitude real not null
);
-- Dbmate schema migrations
INSERT INTO "schema_migrations" (version) VALUES
- ('20250802140659');
+ ('20250802140659'),
+ ('20260802090000');
diff --git a/go.mod b/go.mod
index 63e19a4..99cc6ec 100644
--- a/go.mod
+++ b/go.mod
@@ -5,10 +5,16 @@ go 1.23.0
toolchain go1.23.8
require (
+ github.com/bradleyjkemp/cupaloy v1.3.0
github.com/jftuga/geodist v1.0.0
github.com/martinlehoux/kagamigo v0.4.1
+ github.com/mattn/go-sqlite3 v1.14.16
+ github.com/paulmach/osm v0.9.0
+ github.com/schollz/progressbar/v3 v3.18.0
github.com/stretchr/testify v1.9.0
github.com/tkrajina/gpxgo v1.4.0
+ golang.org/x/text v0.23.0
+ gonum.org/v1/plot v0.16.0
)
require (
@@ -18,10 +24,10 @@ require (
git.sr.ht/~sbinet/gg v0.6.0 // indirect
github.com/BurntSushi/toml v1.4.0 // indirect
github.com/ClickHouse/clickhouse-go v1.5.4 // indirect
+ github.com/DataDog/czlib v0.0.0-20240814115052-86a9592b3985 // indirect
github.com/a-h/templ v0.2.747 // indirect
github.com/ajstarks/svgo v0.0.0-20211024235047-1546f124cd8b // indirect
github.com/amacneil/dbmate v1.16.2 // indirect
- github.com/bradleyjkemp/cupaloy v1.3.0 // indirect
github.com/campoy/embedmd v1.0.0 // indirect
github.com/cloudflare/golz4 v0.0.0-20150217214814-ef862a3cdc58 // indirect
github.com/cpuguy83/go-md2man/v2 v2.0.2 // indirect
@@ -34,21 +40,21 @@ require (
github.com/kr/pretty v0.1.0 // indirect
github.com/kr/text v0.2.0 // indirect
github.com/lib/pq v1.10.7 // indirect
- github.com/mattn/go-sqlite3 v1.14.16 // indirect
github.com/mitchellh/colorstring v0.0.0-20190213212951-d06e56a500db // indirect
+ github.com/paulmach/orb v0.13.0 // indirect
+ github.com/paulmach/protoscan v0.2.1 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/rivo/uniseg v0.4.7 // indirect
github.com/russross/blackfriday/v2 v2.1.0 // indirect
- github.com/schollz/progressbar/v3 v3.18.0 // indirect
github.com/urfave/cli/v2 v2.24.2 // indirect
github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673 // indirect
+ go.mongodb.org/mongo-driver/v2 v2.5.0 // indirect
golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 // indirect
golang.org/x/image v0.25.0 // indirect
golang.org/x/net v0.27.0 // indirect
golang.org/x/sys v0.34.0 // indirect
golang.org/x/term v0.33.0 // indirect
- golang.org/x/text v0.23.0 // indirect
- gonum.org/v1/plot v0.16.0 // indirect
+ google.golang.org/protobuf v1.36.10 // indirect
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 // indirect
gopkg.in/ini.v1 v1.67.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
diff --git a/go.sum b/go.sum
index 0b6280f..5746291 100644
--- a/go.sum
+++ b/go.sum
@@ -1,9 +1,15 @@
+codeberg.org/go-fonts/dejavu v0.4.0 h1:2yn58Vkh4CFK3ipacWUAIE3XVBGNa0y1bc95Bmfx91I=
+codeberg.org/go-fonts/dejavu v0.4.0/go.mod h1:abni088lmhQJvso2Lsb7azCKzwkfcnttl6tL1UTWKzg=
+codeberg.org/go-fonts/latin-modern v0.4.0 h1:vkRCc1y3whKA7iL9Ep0fSGVuJfqjix0ica9UflHORO8=
+codeberg.org/go-fonts/latin-modern v0.4.0/go.mod h1:BF68mZznJ9QHn+hic9ks2DaFl4sR5YhfM6xTYaP9vNw=
codeberg.org/go-fonts/liberation v0.5.0 h1:SsKoMO1v1OZmzkG2DY+7ZkCL9U+rrWI09niOLfQ5Bo0=
codeberg.org/go-fonts/liberation v0.5.0/go.mod h1:zS/2e1354/mJ4pGzIIaEtm/59VFCFnYC7YV6YdGl5GU=
codeberg.org/go-latex/latex v0.1.0 h1:hoGO86rIbWVyjtlDLzCqZPjNykpWQ9YuTZqAzPcfL3c=
codeberg.org/go-latex/latex v0.1.0/go.mod h1:LA0q/AyWIYrqVd+A9Upkgsb+IqPcmSTKc9Dny04MHMw=
codeberg.org/go-pdf/fpdf v0.10.0 h1:u+w669foDDx5Ds43mpiiayp40Ov6sZalgcPMDBcZRd4=
codeberg.org/go-pdf/fpdf v0.10.0/go.mod h1:Y0DGRAdZ0OmnZPvjbMp/1bYxmIPxm0ws4tfoPOc4LjU=
+git.sr.ht/~sbinet/cmpimg v0.1.0 h1:E0zPRk2muWuCqSKSVZIWsgtU9pjsw3eKHi8VmQeScxo=
+git.sr.ht/~sbinet/cmpimg v0.1.0/go.mod h1:FU12psLbF4TfNXkKH2ZZQ29crIqoiqTZmeQ7dkp/pxE=
git.sr.ht/~sbinet/gg v0.6.0 h1:RIzgkizAk+9r7uPzf/VfbJHBMKUr0F5hRFxTUGMnt38=
git.sr.ht/~sbinet/gg v0.6.0/go.mod h1:uucygbfC9wVPQIfrmwM2et0imr8L7KQWywX0xpFMm94=
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
@@ -11,6 +17,8 @@ github.com/BurntSushi/toml v1.4.0 h1:kuoIxZQy2WRRk1pttg9asf+WVv6tWQuBNVmK8+nqPr0
github.com/BurntSushi/toml v1.4.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
github.com/ClickHouse/clickhouse-go v1.5.4 h1:cKjXeYLNWVJIx2J1K6H2CqyRmfwVJVY1OV1coaaFcI0=
github.com/ClickHouse/clickhouse-go v1.5.4/go.mod h1:EaI/sW7Azgz9UATzd5ZdZHRUhHgv5+JMS9NSr2smCJI=
+github.com/DataDog/czlib v0.0.0-20240814115052-86a9592b3985 h1:0nepyu+UcpcOt3rrr0G4PvNDuoEW2aoqtbh2NK0AQ3w=
+github.com/DataDog/czlib v0.0.0-20240814115052-86a9592b3985/go.mod h1:ROY4muaTWpoeQAx/oUkvxe9zKCmgU5xDGXsfEbA+omc=
github.com/a-h/templ v0.2.747 h1:D0dQ2lxC3W7Dxl6fxQ/1zZHBQslSkTSvl5FxP/CfdKg=
github.com/a-h/templ v0.2.747/go.mod h1:69ObQIbrcuwPCU32ohNaWce3Cb7qM5GMiqN1K+2yop4=
github.com/ajstarks/deck v0.0.0-20200831202436-30c9fc6549a9/go.mod h1:JynElWSGnm/4RlzPXRlREEwqTHAN3T56Bv2ITsFT3gY=
@@ -19,11 +27,14 @@ github.com/ajstarks/svgo v0.0.0-20211024235047-1546f124cd8b h1:slYM766cy2nI3BwyR
github.com/ajstarks/svgo v0.0.0-20211024235047-1546f124cd8b/go.mod h1:1KcenG0jGWcpt8ov532z81sp/kMMUG485J2InIOyADM=
github.com/amacneil/dbmate v1.16.2 h1:ovhzYRR2JT5EZbISNtg7MZmLM51ZrHLKoEKMPhiFz5E=
github.com/amacneil/dbmate v1.16.2/go.mod h1:d+2u+wE7GpLepbKxi231FXoi7thXuI1AND5CRG18RcI=
+github.com/bkaradzic/go-lz4 v1.0.0 h1:RXc4wYsyz985CkXXeX04y4VnZFGG8Rd43pRaHsOXAKk=
github.com/bkaradzic/go-lz4 v1.0.0/go.mod h1:0YdlkowM3VswSROI7qDxhRvJ3sLhlFrRRwjwegp5jy4=
github.com/bradleyjkemp/cupaloy v1.3.0 h1:UJ0YJuhkMXEQcaoQNSCmK8og6GEVW/eDhAZuizvcpOY=
github.com/bradleyjkemp/cupaloy v1.3.0/go.mod h1:Au1Xw1sgaJ5iSFktEhYsS0dbQiS1B0/XMXl+42y9Ilk=
github.com/campoy/embedmd v1.0.0 h1:V4kI2qTJJLf4J29RzI/MAt2c3Bl4dQSYPuflzwFH2hY=
github.com/campoy/embedmd v1.0.0/go.mod h1:oxyr9RCiSXg0M3VJ3ks0UGfp98BpSSGr0kpiX3MzVl8=
+github.com/chengxilo/virtualterm v1.0.4 h1:Z6IpERbRVlfB8WkOmtbHiDbBANU7cimRIof7mk9/PwM=
+github.com/chengxilo/virtualterm v1.0.4/go.mod h1:DyxxBZz/x1iqJjFxTFcr6/x+jSpqN0iwWCOK1q10rlY=
github.com/cloudflare/golz4 v0.0.0-20150217214814-ef862a3cdc58 h1:F1EaeKL/ta07PY/k9Os/UFtwERei2/XzGemhpGnBKNg=
github.com/cloudflare/golz4 v0.0.0-20150217214814-ef862a3cdc58/go.mod h1:EOBUe0h4xcZ5GoxqC5SDxFQ8gwyZPKQoEzownBlhI80=
github.com/cpuguy83/go-md2man/v2 v2.0.2 h1:p1EgwI/C7NhT0JmVkwCD2ZBK8j4aeHQX2pMHHBfMQ6w=
@@ -39,8 +50,10 @@ github.com/gofrs/uuid v4.4.0+incompatible h1:3qXRTX8/NbyulANqlc0lchS1gqAVxRgsuW1
github.com/gofrs/uuid v4.4.0+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRxnplIgP/c0N/04lM=
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 h1:DACJavvAHhabrF08vX0COfcOBJRhZ8lUbR+ZWIs0Y5g=
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0/go.mod h1:E/TSTwGwJL78qG/PmXZO1EjYhfJinVAhrmmHX6Z8B9k=
-github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
-github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
+github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
+github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
+github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
+github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/jftuga/geodist v1.0.0 h1:PFPQlZtj10u8ETAYTyxE0DWMl1bwA+Xzrqb4+oLkkC0=
github.com/jftuga/geodist v1.0.0/go.mod h1:BohEDxpZ8S5ADAxW/9EKPSKWOVl0+3wHENIT40m4UO4=
github.com/jmoiron/sqlx v1.2.0/go.mod h1:1FEQNm3xlJgrMD+FBdI9+xvCksHtbpVBBw5dYhBSsks=
@@ -60,11 +73,20 @@ github.com/lib/pq v1.10.7 h1:p7ZhMD+KsSRozJr34udlUrhboJwWAgCg34+/ZZNvZZw=
github.com/lib/pq v1.10.7/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
github.com/martinlehoux/kagamigo v0.4.1 h1:jI4TXyQMdm05H/Pd5RE7pfoMHq9PqdOF21oWAZOxsJ8=
github.com/martinlehoux/kagamigo v0.4.1/go.mod h1:CDkN/Kx8KQSU0kC/ISfq2gYIhVbddHD3E8r3uAlnnMI=
+github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
+github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
github.com/mattn/go-sqlite3 v1.9.0/go.mod h1:FPy6KqzDD04eiIsT53CuJW3U88zkxoIYsOqkbpncsNc=
github.com/mattn/go-sqlite3 v1.14.16 h1:yOQRA0RpS5PFz/oikGwBEqvAWhWg5ufRz4ETLjwpU1Y=
github.com/mattn/go-sqlite3 v1.14.16/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg=
github.com/mitchellh/colorstring v0.0.0-20190213212951-d06e56a500db h1:62I3jR2EmQ4l5rM/4FEfDWcRD+abF5XlKShorW5LRoQ=
github.com/mitchellh/colorstring v0.0.0-20190213212951-d06e56a500db/go.mod h1:l0dey0ia/Uv7NcFFVbCLtqEBQbrT4OCwCSKTEv6enCw=
+github.com/paulmach/orb v0.13.0 h1:r7n7mQGGF+cj/CbcivEj9J3HGK+XR+yXnvzRdq9saIw=
+github.com/paulmach/orb v0.13.0/go.mod h1:6scRWINywA2Jf05dcjOfLfxrUIMECvTSG2MVbRLxu/k=
+github.com/paulmach/osm v0.9.0 h1:hbfe9XSik+TECvwleEn3eUPZSPtlY6otd0MhbnB8aiw=
+github.com/paulmach/osm v0.9.0/go.mod h1:L56sF1Rcd+IC36YkVjPr5FSVuid5sgpYUPgJZzmbSrs=
+github.com/paulmach/protoscan v0.2.1 h1:rM0FpcTjUMvPUNk2BhPJrreDKetq43ChnL+x1sRg8O8=
+github.com/paulmach/protoscan v0.2.1/go.mod h1:SpcSwydNLrxUGSDvXvO0P7g7AuhJ7lcKfDlhJCDw2gY=
+github.com/pierrec/lz4 v2.0.5+incompatible h1:2xWsjqPFWcplujydGg4WmhC/6fZqK42wMM8aXeqhl0I=
github.com/pierrec/lz4 v2.0.5+incompatible/go.mod h1:pdkljMzZIN41W+lC3N2tnIh5sFi+IEE17M5jbnwPHcY=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
@@ -77,8 +99,6 @@ github.com/schollz/progressbar/v3 v3.18.0/go.mod h1:IsO3lpbaGuzh8zIMzgY3+J8l4C8G
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
-github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
-github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/tkrajina/gpxgo v1.4.0 h1:cSD5uSwy3VZuNFieTEZLyRnuIwhonQEkGPkPGW4XNag=
@@ -88,6 +108,10 @@ github.com/urfave/cli/v2 v2.24.2/go.mod h1:GHupkWPMM0M/sj1a2b4wUrWBPzazNrIjouW6f
github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673 h1:bAn7/zixMGCfxrRTfdpNzjtPYqr8smhKouy9mxVdGPU=
github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673/go.mod h1:N3UwUGtsrSj3ccvlPHLoLsHnpR27oXr4ZE984MbSER8=
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
+github.com/zenizh/go-capturer v0.0.0-20211219060012-52ea6c8fed04 h1:qXafrlZL1WsJW5OokjraLLRURHiw0OzKHD/RNdspp4w=
+github.com/zenizh/go-capturer v0.0.0-20211219060012-52ea6c8fed04/go.mod h1:FiwNQxz6hGoNFBC4nIx+CxZhI3nne5RmIOlT/MXcSD4=
+go.mongodb.org/mongo-driver/v2 v2.5.0 h1:yXUhImUjjAInNcpTcAlPHiT7bIXhshCTL3jVBkF3xaE=
+go.mongodb.org/mongo-driver/v2 v2.5.0/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
@@ -125,9 +149,16 @@ golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtn
golang.org/x/tools v0.1.0/go.mod h1:xkSsbof2nBLbhDlRMhhhyNLN/zl3eTqcnHD5viDpcZ0=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
+golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
+gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
+gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
gonum.org/v1/plot v0.16.0 h1:dK28Qx/Ky4VmPUN/2zeW0ELyM6ucDnBAj5yun7M9n1g=
gonum.org/v1/plot v0.16.0/go.mod h1:Xz6U1yDMi6Ni6aaXILqmVIb6Vro8E+K7Q/GeeH+Pn0c=
+google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
+google.golang.org/protobuf v1.27.1/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
+google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE=
+google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 h1:qIbj1fsPNlZgppZ+VLlY7N33q108Sa+fhmuc+sWQYwY=
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
@@ -137,3 +168,5 @@ gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
honnef.co/go/tools v0.1.3/go.mod h1:NgwopIslSNH47DimFoV78dnkksY2EFtX0ajyb3K/las=
+rsc.io/pdf v0.1.1 h1:k1MczvYDUvJBe93bYd7wrZLLUEcLZAuF824/I4e5Xr4=
+rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4=
diff --git a/main.go b/main.go
index 7d37afa..032c55e 100644
--- a/main.go
+++ b/main.go
@@ -1,47 +1,84 @@
package main
import (
+ "context"
"database/sql"
"flag"
"log/slog"
"os"
+ "path/filepath"
"runtime/pprof"
+ "strings"
"time"
- mountainpass "github.com/martinlehoux/biking_home/mountain_pass"
+ "github.com/martinlehoux/biking_home/mountain_pass"
+ "github.com/martinlehoux/biking_home/osmpass"
"github.com/martinlehoux/biking_home/ride"
"github.com/martinlehoux/kagamigo/kcore"
_ "github.com/mattn/go-sqlite3"
)
var (
- download = flag.Bool("download", false, "download mountain passes into the database")
- resume = flag.Bool("resume", false, "skip departments already cached on disk")
- demo = flag.Bool("demo", false, "run the climb similarity demo")
- cpuprofile = flag.String("cpuprofile", "", "write cpu profile to file")
- parser = ride.GPXRideParser{}
+ download = flag.Bool("download", false, "download mountain passes into the database")
+ resume = flag.Bool("resume", false, "skip departments already cached on disk")
+ importCache = flag.Bool("import-cached", false, "import cached department CSVs into the database")
+ fetchOSM = flag.Bool("fetch-osm", false, "download the France OSM PBF (resumable) into france-latest.osm.pbf")
+ extractOSM = flag.String("extract-osm", "", "extract mountain passes from an OSM PBF file into the database")
+ enrich = flag.Bool("enrich", false, "backfill mountain pass coordinates from OSM data")
+ demo = flag.Bool("demo", false, "run the climb similarity demo")
+ chartFile = flag.String("chart", "", "render a climb/pass chart for a GPX file")
+ cpuprofile = flag.String("cpuprofile", "", "write cpu profile to file")
+ parser = ride.GPXRideParser{}
)
func main() {
flag.Parse()
- if *download {
- runDownload()
- return
- }
- if *demo {
- runDemo()
- }
-}
-
-func runDownload() {
db, err := sql.Open("sqlite3", "biking_home.db")
kcore.Expect(err, "failed to open database")
defer db.Close()
- err = mountainpass.DownloadMountainPasses(db, 5*time.Second, *resume)
- kcore.Expect(err, "failed to download mountain passes")
+ switch {
+ case *download:
+ err = mountain_pass.DownloadMountainPasses(db, 5*time.Second, *resume)
+ kcore.Expect(err, "failed to download mountain passes")
+ case *importCache:
+ _, err = mountain_pass.ImportCachedDepartments(db, []string{"06", "13"})
+ kcore.Expect(err, "failed to import cached departments")
+ case *fetchOSM:
+ err = osmpass.FetchFrancePBF("france-latest.osm.pbf")
+ kcore.Expect(err, "failed to download France OSM PBF")
+ case *extractOSM != "":
+ _, err = osmpass.ExtractMountainPasses(context.Background(), *extractOSM, db)
+ kcore.Expect(err, "failed to extract mountain passes from OSM")
+ case *enrich:
+ _, err = osmpass.EnrichMountainPasses(db)
+ kcore.Expect(err, "failed to enrich mountain passes")
+ case *demo:
+ runDemo(db)
+ case *chartFile != "":
+ runChart(db, *chartFile)
+ }
+}
+
+func runChart(db *sql.DB, filename string) {
+ r, err := ride.ParseFile(parser, filename)
+ kcore.Expect(err, "failed to parse ride")
+ passes, err := mountain_pass.LoadMountainPasses(db)
+ kcore.Expect(err, "failed to load mountain passes")
+
+ climbs := r.AllClimbs()
+ for i := range climbs {
+ if matched, ok := mountain_pass.MatchClimb(climbs[i], passes, 300, 50); ok {
+ climbs[i].Name = matched.Name
+ }
+ }
+ crossings := mountain_pass.DetectCrossings(r, passes, 100, 25)
+
+ output := strings.TrimSuffix(filename, filepath.Ext(filename)) + "-chart.png"
+ renderChart(r, climbs, crossings, output)
+ slog.Info("Chart written", "file", output)
}
-func runDemo() {
+func runDemo(db *sql.DB) {
if *cpuprofile != "" {
f, err := os.Create(*cpuprofile)
kcore.Expect(err, "failed to create CPU profile")
@@ -54,10 +91,63 @@ func runDemo() {
for _, r := range []*ride.Ride{&ride19Jan, &ride30Mar, &ride20Apr} {
slog.Info("Ride", "difficulty", r.DifficultyScore())
}
+ passes, passesErr := mountain_pass.LoadMountainPasses(db)
+ if passesErr != nil {
+ slog.Warn("No mountain passes in database, skipping crossings and climb naming", "error", passesErr)
+ passes = nil
+ } else {
+ rides := []struct {
+ name string
+ ride ride.Ride
+ }{
+ {"2022-07-21.Pogacar", func() ride.Ride {
+ r, _ := ride.ParseFile(parser, "examples/2022-07-21.Pogacar.gpx")
+ return r
+ }()},
+ {"2023-06-17.AlpesVerdonTour", func() ride.Ride {
+ r, _ := ride.ParseFile(parser, "examples/2023-06-17.AlpesVerdonTour.gpx")
+ return r
+ }()},
+ {"2024-12-29.MimetArbois", func() ride.Ride {
+ r, _ := ride.ParseFile(parser, "examples/2024-12-29.MimetArbois.gpx")
+ return r
+ }()},
+ {"activity_18043356988", ride19Jan},
+ {"activity_18679866717", ride30Mar},
+ {"activity_18880605641", ride20Apr},
+ }
+ for _, entry := range rides {
+ crossings := mountain_pass.DetectCrossings(entry.ride, passes, 100, 25)
+ if len(crossings) > 0 {
+ slog.Info("Crossings", "ride", entry.name, "count", len(crossings))
+ for _, crossing := range crossings {
+ slog.Info("Crossing", "pass", crossing.String())
+ }
+ }
+ }
+ }
+
+ nameClimbs := func(climbs []ride.Climb) {
+ for i := range climbs {
+ if matched, ok := mountain_pass.MatchClimb(climbs[i], passes, 300, 50); ok {
+ climbs[i].Name = matched.Name
+ }
+ }
+ }
+
+ verdon, _ := ride.ParseFile(parser, "examples/2023-06-17.AlpesVerdonTour.gpx")
+ verdonClimbs := verdon.AllClimbs()
+ nameClimbs(verdonClimbs)
+ for _, climb := range verdonClimbs {
+ slog.Info("Climb", "climb", climb, "elevation", climb.Top().ElevationM, "duration", climb.Duration(), "speed", climb.Speed()*3.6)
+ }
+
index := ride.NewFlatRideClimbsIndex(30)
index.Insert(ride19Jan)
index.Insert(ride30Mar)
- for _, climb := range ride20Apr.AllClimbs() {
+ ride20AprilClimbs := ride20Apr.AllClimbs()
+ nameClimbs(ride20AprilClimbs)
+ for _, climb := range ride20AprilClimbs {
slog.Info("Climb", "climb", climb, "duration", climb.Duration(), "speed", climb.Speed()*3.6)
if len(index.Similar(ride20Apr, climb)) > 0 {
for _, similar := range index.Similar(ride20Apr, climb) {
@@ -65,7 +155,9 @@ func runDemo() {
}
}
}
- for _, climb := range ride30Mar.AllClimbs() {
+ ride30MarchClimbs := ride30Mar.AllClimbs()
+ nameClimbs(ride30MarchClimbs)
+ for _, climb := range ride30MarchClimbs {
slog.Info("Climb", "climb", climb, "duration", climb.Duration(), "speed", climb.Speed()*3.6)
}
ride.PlotScore(&ride30Mar, 61.2, 66.7, "Ride 30 Mar.png")
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)
+}
diff --git a/mountain_pass/detection_test.go b/mountain_pass/detection_test.go
new file mode 100644
index 0000000..3208061
--- /dev/null
+++ b/mountain_pass/detection_test.go
@@ -0,0 +1,109 @@
+package mountain_pass_test
+
+import (
+ "testing"
+
+ "github.com/jftuga/geodist"
+ "github.com/martinlehoux/biking_home/mountain_pass"
+ "github.com/martinlehoux/biking_home/ride"
+ "github.com/stretchr/testify/assert"
+)
+
+func TestDetectCrossingsFindsPass(t *testing.T) {
+ r := rideFromPoint(t, 43.62, 5.43, 447)
+ pass := mountain_pass.MountainPass{
+ Name: "Pas de Magnan", Elevation: 460,
+ Coord: &geodist.Coord{Lat: 43.62, Lon: 5.43},
+ }
+ crossings := mountain_pass.DetectCrossings(r, []mountain_pass.MountainPass{pass}, 100, 25)
+ assert.Len(t, crossings, 1)
+ assert.Equal(t, "Pas de Magnan", crossings[0].Pass.Name)
+ assert.Less(t, crossings[0].DistanceToM, 10.0)
+ assert.Less(t, crossings[0].ElevationDiff, 25.0)
+}
+
+func TestDetectCrossingsIgnoresDistantPass(t *testing.T) {
+ r := rideFromPoint(t, 43.62, 5.43, 447)
+ far := mountain_pass.MountainPass{
+ Name: "Col de la Faucille", Elevation: 1320,
+ Coord: &geodist.Coord{Lat: 46.37, Lon: 6.02},
+ }
+ assert.Empty(t, mountain_pass.DetectCrossings(r, []mountain_pass.MountainPass{far}, 100, 25))
+}
+
+func TestDetectCrossingsIgnoresPassWithoutCoordinates(t *testing.T) {
+ r := rideFromPoint(t, 43.62, 5.43, 447)
+ noCoord := mountain_pass.MountainPass{Name: "Col de la Gatasse", Elevation: 120}
+ assert.Empty(t, mountain_pass.DetectCrossings(r, []mountain_pass.MountainPass{noCoord}, 100, 25))
+}
+
+func TestDetectCrossingsRequiresElevationAgreement(t *testing.T) {
+ r := rideFromPoint(t, 43.62, 5.43, 447)
+ pass := mountain_pass.MountainPass{
+ Name: "Pas de Magnan", Elevation: 900,
+ Coord: &geodist.Coord{Lat: 43.62, Lon: 5.43},
+ }
+ assert.Empty(t, mountain_pass.DetectCrossings(r, []mountain_pass.MountainPass{pass}, 100, 25))
+}
+
+func rideFromPoint(t *testing.T, latitude, longitude, elevation float64) ride.Ride {
+ t.Helper()
+ points := []ride.Point{
+ {DistanceM: 0, ElevationM: 100, Coord: geodist.Coord{Lat: latitude - 0.01, Lon: longitude - 0.01}},
+ {DistanceM: 1000, ElevationM: elevation, Coord: geodist.Coord{Lat: latitude, Lon: longitude}},
+ }
+ return ride.FromPoints(points)
+}
+
+func TestMatchClimbFindsPassAtTop(t *testing.T) {
+ climb := climbFromPoints([]ride.Point{
+ {DistanceM: 0, ElevationM: 300, Coord: geodist.Coord{Lat: 43.61, Lon: 5.42}},
+ {DistanceM: 1000, ElevationM: 447, Coord: geodist.Coord{Lat: 43.62, Lon: 5.43}},
+ {DistanceM: 2000, ElevationM: 380, Coord: geodist.Coord{Lat: 43.63, Lon: 5.44}},
+ })
+ pass := mountain_pass.MountainPass{
+ Name: "Pas de Magnan", Elevation: 440,
+ Coord: &geodist.Coord{Lat: 43.62, Lon: 5.43},
+ }
+ matched, found := mountain_pass.MatchClimb(climb, []mountain_pass.MountainPass{pass}, 100, 25)
+ assert.True(t, found)
+ assert.Equal(t, "Pas de Magnan", matched.Name)
+}
+
+func TestMatchClimbRequiresElevationAgreement(t *testing.T) {
+ climb := climbFromPoints([]ride.Point{
+ {DistanceM: 0, ElevationM: 300, Coord: geodist.Coord{Lat: 43.61, Lon: 5.42}},
+ {DistanceM: 1000, ElevationM: 447, Coord: geodist.Coord{Lat: 43.62, Lon: 5.43}},
+ {DistanceM: 2000, ElevationM: 380, Coord: geodist.Coord{Lat: 43.63, Lon: 5.44}},
+ })
+ pass := mountain_pass.MountainPass{
+ Name: "Pas de Magnan", Elevation: 900,
+ Coord: &geodist.Coord{Lat: 43.62, Lon: 5.43},
+ }
+ _, found := mountain_pass.MatchClimb(climb, []mountain_pass.MountainPass{pass}, 100, 25)
+ assert.False(t, found)
+}
+
+func TestMatchClimbNearestOfTwo(t *testing.T) {
+ climb := climbFromPoints([]ride.Point{
+ {DistanceM: 0, ElevationM: 300, Coord: geodist.Coord{Lat: 43.61, Lon: 5.42}},
+ {DistanceM: 1000, ElevationM: 447, Coord: geodist.Coord{Lat: 43.62, Lon: 5.43}},
+ {DistanceM: 2000, ElevationM: 380, Coord: geodist.Coord{Lat: 43.63, Lon: 5.44}},
+ })
+ near := mountain_pass.MountainPass{
+ Name: "Pas de Magnan", Elevation: 440,
+ Coord: &geodist.Coord{Lat: 43.62, Lon: 5.43},
+ }
+ far := mountain_pass.MountainPass{
+ Name: "Col lointain", Elevation: 440,
+ Coord: &geodist.Coord{Lat: 43.6201, Lon: 5.4301},
+ }
+ matched, found := mountain_pass.MatchClimb(climb, []mountain_pass.MountainPass{near, far}, 100, 25)
+ assert.True(t, found)
+ assert.Equal(t, "Pas de Magnan", matched.Name)
+}
+
+func climbFromPoints(points []ride.Point) ride.Climb {
+ ride := ride.FromPoints(points)
+ return ride.ClimbFromDist(0, 2000)
+}
diff --git a/mountain_pass/downloader.go b/mountain_pass/downloader.go
index 685e0fe..00ff586 100644
--- a/mountain_pass/downloader.go
+++ b/mountain_pass/downloader.go
@@ -1,4 +1,4 @@
-package mountainpass
+package mountain_pass
import (
"bytes"
@@ -14,6 +14,7 @@ import (
"strings"
"time"
+ "github.com/jftuga/geodist"
"github.com/martinlehoux/kagamigo/kcore"
"github.com/schollz/progressbar/v3"
)
@@ -24,6 +25,7 @@ type MountainPass struct {
CountryCode string
DepartmentCode string
Elevation int
+ Coord *geodist.Coord
}
func parseMountainPasses(reader io.Reader) ([]MountainPass, error) {
@@ -146,12 +148,12 @@ func downloadDepartment(departmentCode string, resume bool) ([]MountainPass, err
}
mountainPasses, err := parseMountainPasses(bytes.NewReader(data))
if err != nil {
- if writeErr := os.WriteFile(filename, data, 0644); writeErr != nil {
+ if writeErr := os.WriteFile(filename, data, 0o644); writeErr != nil {
return nil, kcore.Wrap(writeErr, "Failed to write dump file")
}
return nil, err
}
- if writeErr := os.WriteFile(filename, data, 0644); writeErr != nil {
+ if writeErr := os.WriteFile(filename, data, 0o644); writeErr != nil {
return nil, kcore.Wrap(writeErr, "Failed to write dump file")
}
return mountainPasses, nil
@@ -159,22 +161,7 @@ func downloadDepartment(departmentCode string, resume bool) ([]MountainPass, err
return nil, lastErr
}
-func DownloadMountainPasses(db *sql.DB, delay time.Duration, resume bool) error {
- mountainPasses := make([]MountainPass, 0)
- bar := progressbar.Default(90)
- ticker := time.NewTicker(delay)
- for i := 1; i <= 90; i++ {
- <-ticker.C
- bar.Add(1)
- departmentCode := fmt.Sprintf("%02d", i)
- bar.Describe(fmt.Sprintf("Downloading mountain passes for department_code=%s", departmentCode))
- departmentMountainPasses, err := downloadDepartment(departmentCode, resume)
- if err != nil {
- return kcore.Wrap(err, "Failed to download mountain passes for department "+departmentCode)
- }
- mountainPasses = append(mountainPasses, departmentMountainPasses...)
- }
-
+func upsertMountainPasses(db *sql.DB, mountainPasses []MountainPass) error {
tx, err := db.Begin()
kcore.Expect(err, "Failed to begin transaction")
defer tx.Rollback()
@@ -197,3 +184,39 @@ func DownloadMountainPasses(db *sql.DB, delay time.Duration, resume bool) error
return tx.Commit()
}
+
+func DownloadMountainPasses(db *sql.DB, delay time.Duration, resume bool) error {
+ mountainPasses := make([]MountainPass, 0)
+ bar := progressbar.Default(90)
+ ticker := time.NewTicker(delay)
+ for i := 1; i <= 90; i++ {
+ <-ticker.C
+ bar.Add(1)
+ departmentCode := fmt.Sprintf("%02d", i)
+ bar.Describe(fmt.Sprintf("Downloading mountain passes for department_code=%s", departmentCode))
+ departmentMountainPasses, err := downloadDepartment(departmentCode, resume)
+ if err != nil {
+ return kcore.Wrap(err, "Failed to download mountain passes for department "+departmentCode)
+ }
+ mountainPasses = append(mountainPasses, departmentMountainPasses...)
+ }
+
+ return upsertMountainPasses(db, mountainPasses)
+}
+
+func ImportCachedDepartments(db *sql.DB, departmentCodes []string) (int, error) {
+ mountainPasses := make([]MountainPass, 0)
+ for _, departmentCode := range departmentCodes {
+ departmentMountainPasses, found, err := loadCachedDepartment(departmentCacheFile(departmentCode))
+ if err != nil {
+ return 0, err
+ }
+ if !found {
+ slog.Warn("No cached department file", "department", departmentCode)
+ continue
+ }
+ slog.Info("Loaded cached department", "department", departmentCode, "passes", len(departmentMountainPasses))
+ mountainPasses = append(mountainPasses, departmentMountainPasses...)
+ }
+ return len(mountainPasses), upsertMountainPasses(db, mountainPasses)
+}
diff --git a/mountain_pass/downloader_test.go b/mountain_pass/downloader_test.go
index f4ffcc8..f7f5dda 100644
--- a/mountain_pass/downloader_test.go
+++ b/mountain_pass/downloader_test.go
@@ -1,4 +1,4 @@
-package mountainpass
+package mountain_pass
import (
"os"
@@ -47,7 +47,7 @@ func TestSplitCountryDepartment(t *testing.T) {
func TestLoadCachedDepartment(t *testing.T) {
cacheFile := filepath.Join(t.TempDir(), "debug_department_01.csv")
- err := os.WriteFile(cacheFile, []byte("Brevet\tcode\tnom\taltitude\nFR-01\tFR-01-1500\tCol du Colombier\t1498\n"), 0644)
+ err := os.WriteFile(cacheFile, []byte("Brevet\tcode\tnom\taltitude\nFR-01\tFR-01-1500\tCol du Colombier\t1498\n"), 0o644)
require.NoError(t, err)
mountainPasses, found, err := loadCachedDepartment(cacheFile)
@@ -65,7 +65,7 @@ func TestLoadCachedDepartmentMissing(t *testing.T) {
func TestLoadCachedDepartmentCorrupt(t *testing.T) {
cacheFile := filepath.Join(t.TempDir(), "debug_department_01.csv")
- err := os.WriteFile(cacheFile, []byte("Brevet\tcode\tnom\taltitude\nFR-01\n"), 0644)
+ err := os.WriteFile(cacheFile, []byte("Brevet\tcode\tnom\taltitude\nFR-01\n"), 0o644)
require.NoError(t, err)
mountainPasses, found, err := loadCachedDepartment(cacheFile)
diff --git a/osmpass/enrich.go b/osmpass/enrich.go
new file mode 100644
index 0000000..3af0067
--- /dev/null
+++ b/osmpass/enrich.go
@@ -0,0 +1,240 @@
+package osmpass
+
+import (
+ "database/sql"
+ "fmt"
+ "log/slog"
+ "strings"
+ "unicode"
+
+ "golang.org/x/text/unicode/norm"
+)
+
+const elevationToleranceM = 25
+
+type osmPass struct {
+ Name string
+ Elevation *int
+ Latitude float64
+ Longitude float64
+}
+
+func EnrichMountainPasses(db *sql.DB) (int, error) {
+ osmPasses, err := loadOSMPasses(db)
+ if err != nil {
+ return 0, err
+ }
+ rows, err := db.Query(`
+ SELECT external_id, name, department_code, elevation
+ FROM mountain_passes
+ WHERE latitude IS NULL
+ `)
+ if err != nil {
+ return 0, err
+ }
+
+ unmatched := make([]struct {
+ ExternalID string
+ Name string
+ DepartmentCode string
+ Elevation int
+ }, 0)
+ for rows.Next() {
+ var candidate struct {
+ ExternalID string
+ Name string
+ DepartmentCode string
+ Elevation int
+ }
+ if err := rows.Scan(&candidate.ExternalID, &candidate.Name, &candidate.DepartmentCode, &candidate.Elevation); err != nil {
+ rows.Close()
+ return 0, err
+ }
+ unmatched = append(unmatched, candidate)
+ }
+ if err := rows.Err(); err != nil {
+ rows.Close()
+ return 0, err
+ }
+ rows.Close()
+
+ statement, err := db.Prepare(`
+ UPDATE mountain_passes SET latitude = ?, longitude = ? WHERE external_id = ?
+ `)
+ if err != nil {
+ return 0, err
+ }
+ defer statement.Close()
+
+ count := 0
+ for _, candidate := range unmatched {
+ matched := matchToOSM(candidate.Name, candidate.DepartmentCode, candidate.Elevation, osmPasses)
+ if matched == nil {
+ continue
+ }
+ if _, err := statement.Exec(matched.Latitude, matched.Longitude, candidate.ExternalID); err != nil {
+ return count, err
+ }
+ count++
+ }
+ slog.Info("Enriched mountain passes with coordinates", "count", count)
+ return count, nil
+}
+
+func loadOSMPasses(db *sql.DB) ([]osmPass, error) {
+ rows, err := db.Query(`
+ SELECT name, elevation, latitude, longitude
+ FROM osm_passes
+ WHERE elevation IS NOT NULL
+ `)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+
+ osmPasses := make([]osmPass, 0)
+ for rows.Next() {
+ var mountainPass osmPass
+ var name sql.NullString
+ var elevation sql.NullInt64
+ if err := rows.Scan(&name, &elevation, &mountainPass.Latitude, &mountainPass.Longitude); err != nil {
+ return nil, err
+ }
+ mountainPass.Name = name.String
+ if elevation.Valid {
+ elevationValue := int(elevation.Int64)
+ mountainPass.Elevation = &elevationValue
+ }
+ osmPasses = append(osmPasses, mountainPass)
+ }
+ return osmPasses, rows.Err()
+}
+
+func matchToOSM(name, departmentCode string, elevation int, osmPasses []osmPass) *osmPass {
+ bbox := departmentBBox(departmentCode)
+ var best *osmPass
+ bestNameMatch := false
+ bestElevationDiff := 0.0
+ for i := range osmPasses {
+ candidate := &osmPasses[i]
+ if candidate.Elevation == nil {
+ continue
+ }
+ if !bbox.Contains(candidate.Latitude, candidate.Longitude) {
+ continue
+ }
+ elevationDiff := absFloat64(float64(elevation) - float64(*candidate.Elevation))
+ if elevationDiff > elevationToleranceM {
+ continue
+ }
+ nameMatch := nameMatches(name, candidate.Name)
+ if best == nil ||
+ (nameMatch && !bestNameMatch) ||
+ (nameMatch == bestNameMatch && elevationDiff < bestElevationDiff) {
+ best = candidate
+ bestNameMatch = nameMatch
+ bestElevationDiff = elevationDiff
+ }
+ }
+ if best == nil {
+ return nil
+ }
+ if bestNameMatch {
+ return best
+ }
+ for i := range osmPasses {
+ candidate := &osmPasses[i]
+ if candidate == best || candidate.Elevation == nil {
+ continue
+ }
+ if !bbox.Contains(candidate.Latitude, candidate.Longitude) {
+ continue
+ }
+ if absFloat64(float64(elevation)-float64(*candidate.Elevation)) <= bestElevationDiff+30 {
+ return nil
+ }
+ }
+ return best
+}
+
+type bbox struct {
+ minLat, minLon, maxLat, maxLon float64
+}
+
+func (b bbox) Contains(latitude, longitude float64) bool {
+ return latitude >= b.minLat && latitude <= b.maxLat &&
+ longitude >= b.minLon && longitude <= b.maxLon
+}
+
+func departmentBBox(departmentCode string) bbox {
+ switch departmentCode {
+ case "01":
+ return bbox{minLat: 45.5, minLon: 4.7, maxLat: 46.6, maxLon: 6.3}
+ case "06":
+ return bbox{minLat: 43.5, minLon: 6.4, maxLat: 44.5, maxLon: 7.8}
+ case "13":
+ return bbox{minLat: 43.1, minLon: 4.4, maxLat: 44.0, maxLon: 6.0}
+ default:
+ return bbox{minLat: 41.0, minLon: -6.0, maxLat: 52.0, maxLon: 10.0}
+ }
+}
+
+func nameMatches(centName, osmName string) bool {
+ cent := normalizeName(centName)
+ osm := normalizeName(osmName)
+ if cent == osm {
+ return true
+ }
+ centTokens := tokenize(cent)
+ if len(centTokens) < 3 {
+ return false
+ }
+ osmTokens := make(map[string]bool)
+ for _, token := range tokenize(osm) {
+ osmTokens[token] = true
+ }
+ for _, token := range centTokens {
+ if !osmTokens[token] {
+ return false
+ }
+ }
+ return true
+}
+
+func tokenize(name string) []string {
+ return strings.Fields(name)
+}
+
+func normalizeName(name string) string {
+ name = strings.ToLower(name)
+ name = norm.NFD.String(name)
+ normalized := strings.Builder{}
+ previousSpace := false
+ for _, r := range name {
+ if unicode.Is(unicode.Mn, r) {
+ continue
+ }
+ if unicode.IsLetter(r) || unicode.IsDigit(r) {
+ normalized.WriteRune(r)
+ previousSpace = false
+ } else if !previousSpace {
+ normalized.WriteRune(' ')
+ previousSpace = true
+ }
+ }
+ return strings.TrimSpace(normalized.String())
+}
+
+func absFloat64(value float64) float64 {
+ if value < 0 {
+ return -value
+ }
+ return value
+}
+
+func (p osmPass) String() string {
+ if p.Elevation == nil {
+ return fmt.Sprintf("%s @ unknown elevation", p.Name)
+ }
+ return fmt.Sprintf("%s @ %dm", p.Name, *p.Elevation)
+}
diff --git a/osmpass/enrich_test.go b/osmpass/enrich_test.go
new file mode 100644
index 0000000..88c6a3f
--- /dev/null
+++ b/osmpass/enrich_test.go
@@ -0,0 +1,149 @@
+package osmpass
+
+import (
+ "database/sql"
+ "testing"
+
+ _ "github.com/mattn/go-sqlite3"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestNormalizeName(t *testing.T) {
+ assert.Equal(t, "col du telegraphe", normalizeName("Col du Télégraphe"))
+ assert.Equal(t, "col de la gineste", normalizeName("Col de la Gineste"))
+ assert.Equal(t, normalizeName("Col de la Gineste"), normalizeName("col de la gineste"))
+ assert.True(t, nameMatches("Col de la Lombarde", "Col de la Lombarde / Colle della Lombarda"))
+ assert.False(t, nameMatches("Col de la Gatasse", "Col de la Gatasso"))
+}
+
+func TestEnrichMountainPasses(t *testing.T) {
+ db := testDB(t, `
+ CREATE TABLE osm_passes (
+ osm_id integer unique not null,
+ name text,
+ elevation integer,
+ latitude real not null,
+ longitude real not null
+ );
+ CREATE TABLE mountain_passes (
+ external_id text unique not null,
+ name text not null,
+ department_code text not null,
+ elevation integer not null,
+ latitude real,
+ longitude real
+ );
+ `)
+
+ insert(t, db, "INSERT INTO osm_passes VALUES (1, 'Col de la Gineste', 327, 43.2, 5.4)")
+ insert(t, db, "INSERT INTO osm_passes VALUES (2, 'Col de la Couillole', 1678, 44.1, 7.0)")
+ insert(t, db, "INSERT INTO osm_passes VALUES (3, 'Col de la Gatasso', 122, 43.3, 5.5)")
+ insert(t, db, "INSERT INTO osm_passes VALUES (4, 'Col des Portes', NULL, 43.6, 5.8)")
+
+ insert(t, db, "INSERT INTO mountain_passes VALUES ('c/1', 'Col de la Gineste', '13', 326, NULL, NULL)")
+ insert(t, db, "INSERT INTO mountain_passes VALUES ('c/2', 'Col de la Gatasse', '13', 120, NULL, NULL)")
+ insert(t, db, "INSERT INTO mountain_passes VALUES ('c/3', 'Col inconnu', '13', 999, NULL, NULL)")
+
+ count, err := EnrichMountainPasses(db)
+ require.NoError(t, err)
+ assert.Equal(t, 2, count)
+
+ var latitude, longitude float64
+ err = db.QueryRow("SELECT latitude, longitude FROM mountain_passes WHERE external_id = 'c/1'").Scan(&latitude, &longitude)
+ require.NoError(t, err)
+ assert.InDelta(t, 43.2, latitude, 1e-9)
+ assert.InDelta(t, 5.4, longitude, 1e-9)
+
+ err = db.QueryRow("SELECT latitude, longitude FROM mountain_passes WHERE external_id = 'c/2'").Scan(&latitude, &longitude)
+ require.NoError(t, err)
+ assert.InDelta(t, 43.3, latitude, 1e-9)
+ assert.InDelta(t, 5.5, longitude, 1e-9)
+
+ var isNull bool
+ err = db.QueryRow("SELECT latitude IS NULL FROM mountain_passes WHERE external_id = 'c/3'").Scan(&isNull)
+ require.NoError(t, err)
+ assert.True(t, isNull)
+}
+
+func TestEnrichMountainPassesAmbiguousElevationOnly(t *testing.T) {
+ db := testDB(t, `
+ CREATE TABLE osm_passes (
+ osm_id integer unique not null,
+ name text,
+ elevation integer,
+ latitude real not null,
+ longitude real not null
+ );
+ CREATE TABLE mountain_passes (
+ external_id text unique not null,
+ name text not null,
+ department_code text not null,
+ elevation integer not null,
+ latitude real,
+ longitude real
+ );
+ `)
+
+ insert(t, db, "INSERT INTO osm_passes VALUES (1, 'Autre Col A', 122, 43.3, 5.5)")
+ insert(t, db, "INSERT INTO osm_passes VALUES (2, 'Autre Col B', 130, 43.5, 5.7)")
+
+ insert(t, db, "INSERT INTO mountain_passes VALUES ('c/1', 'Col de la Gatasse', '13', 120, NULL, NULL)")
+
+ count, err := EnrichMountainPasses(db)
+ require.NoError(t, err)
+ assert.Zero(t, count)
+
+ var isNull bool
+ err = db.QueryRow("SELECT latitude IS NULL FROM mountain_passes WHERE external_id = 'c/1'").Scan(&isNull)
+ require.NoError(t, err)
+ assert.True(t, isNull)
+}
+
+func TestEnrichMountainPassesRejectsCrossDepartmentHomonym(t *testing.T) {
+ db := testDB(t, `
+ CREATE TABLE osm_passes (
+ osm_id integer unique not null,
+ name text,
+ elevation integer,
+ latitude real not null,
+ longitude real not null
+ );
+ CREATE TABLE mountain_passes (
+ external_id text unique not null,
+ name text not null,
+ department_code text not null,
+ elevation integer not null,
+ latitude real,
+ longitude real
+ );
+ `)
+
+ insert(t, db, "INSERT INTO osm_passes VALUES (1, 'Collet de la Selle', 1178, 43.77, 6.81)")
+
+ insert(t, db, "INSERT INTO mountain_passes VALUES ('c/1', 'La Selle', '01', 1175, NULL, NULL)")
+
+ count, err := EnrichMountainPasses(db)
+ require.NoError(t, err)
+ assert.Zero(t, count)
+
+ var isNull bool
+ err = db.QueryRow("SELECT latitude IS NULL FROM mountain_passes WHERE external_id = 'c/1'").Scan(&isNull)
+ require.NoError(t, err)
+ assert.True(t, isNull)
+}
+
+func testDB(t *testing.T, schema string) *sql.DB {
+ db, err := sql.Open("sqlite3", ":memory:")
+ require.NoError(t, err)
+ db.SetMaxOpenConns(1)
+ t.Cleanup(func() { db.Close() })
+ _, err = db.Exec(schema)
+ require.NoError(t, err)
+ return db
+}
+
+func insert(t *testing.T, db *sql.DB, query string) {
+ _, err := db.Exec(query)
+ require.NoError(t, err)
+}
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
+}
diff --git a/osmpass/extract_test.go b/osmpass/extract_test.go
new file mode 100644
index 0000000..bd31ebb
--- /dev/null
+++ b/osmpass/extract_test.go
@@ -0,0 +1,49 @@
+package osmpass
+
+import (
+ "testing"
+
+ "github.com/paulmach/osm"
+ "github.com/stretchr/testify/assert"
+)
+
+func TestIsMountainPass(t *testing.T) {
+ cases := []struct {
+ name string
+ tags map[string]string
+ want bool
+ }{
+ {"tagged", map[string]string{"mountain_pass": "yes", "name": "Col de la Gineste"}, true},
+ {"legacy natural", map[string]string{"natural": "mountain_pass"}, true},
+ {"not a pass", map[string]string{"natural": "saddle", "name": "Col de la Gineste"}, false},
+ {"no tags", map[string]string{}, false},
+ }
+ for _, c := range cases {
+ t.Run(c.name, func(t *testing.T) {
+ node := &osm.Node{Tags: osm.Tags{}}
+ for key, value := range c.tags {
+ node.Tags = append(node.Tags, osm.Tag{Key: key, Value: value})
+ }
+ assert.Equal(t, c.want, isMountainPass(node))
+ })
+ }
+}
+
+func TestParseElevation(t *testing.T) {
+ cases := []struct {
+ input string
+ want int
+ ok bool
+ }{
+ {"1320", 1320, true},
+ {"1320 m", 1320, true},
+ {" 447.5", 447, true},
+ {"", 0, false},
+ {"unknown", 0, false},
+ }
+ for _, c := range cases {
+ value, ok := parseElevation(c.input)
+ assert.Equal(t, c.want, value)
+ assert.Equal(t, c.ok, ok)
+ }
+}
diff --git a/osmpass/fetch.go b/osmpass/fetch.go
new file mode 100644
index 0000000..6abd136
--- /dev/null
+++ b/osmpass/fetch.go
@@ -0,0 +1,98 @@
+package osmpass
+
+import (
+ "errors"
+ "fmt"
+ "io"
+ "log/slog"
+ "net/http"
+ "os"
+
+ "github.com/schollz/progressbar/v3"
+)
+
+const francePBFRUL = "https://download.geofabrik.de/europe/france-latest.osm.pbf"
+
+// FetchFrancePBF downloads the full France OSM PBF to destPath, resuming a
+// partial download when the file already exists and skipping the download
+// entirely when it is already complete.
+func FetchFrancePBF(destPath string) error {
+ client := &http.Client{}
+
+ head, err := client.Head(francePBFRUL)
+ if err != nil {
+ return fmt.Errorf("failed to check remote size: %w", err)
+ }
+ head.Body.Close()
+ total := head.ContentLength
+ if total <= 0 {
+ return fmt.Errorf("unexpected remote content length: %d", total)
+ }
+
+ info, statErr := os.Stat(destPath)
+ var offset int64
+ if statErr == nil {
+ offset = info.Size()
+ if offset >= total {
+ slog.Info("Already downloaded", "file", destPath, "bytes", offset)
+ return nil
+ }
+ if offset > 0 {
+ slog.Info("Resuming download", "file", destPath, "bytes", offset, "remaining", total-offset)
+ }
+ } else if !errors.Is(statErr, os.ErrNotExist) {
+ return fmt.Errorf("failed to stat %s: %w", destPath, statErr)
+ }
+
+ req, err := http.NewRequest(http.MethodGet, francePBFRUL, nil)
+ if err != nil {
+ return err
+ }
+ req.Header.Set("User-Agent", "biking_home")
+ if offset > 0 {
+ req.Header.Set("Range", fmt.Sprintf("bytes=%d-", offset))
+ }
+
+ res, err := client.Do(req)
+ if err != nil {
+ return err
+ }
+ defer res.Body.Close()
+
+ resuming := offset > 0 && res.StatusCode == http.StatusPartialContent
+ if !resuming {
+ if res.StatusCode != http.StatusOK {
+ return fmt.Errorf("unexpected status code: %d", res.StatusCode)
+ }
+ if offset > 0 {
+ slog.Warn("Server ignored Range header, restarting download", "file", destPath)
+ }
+ offset = 0
+ }
+
+ mode := os.O_CREATE | os.O_WRONLY | os.O_TRUNC
+ if resuming {
+ mode = os.O_CREATE | os.O_WRONLY
+ }
+ file, err := os.OpenFile(destPath, mode, 0o644)
+ if err != nil {
+ return err
+ }
+ defer file.Close()
+
+ bar := progressbar.DefaultBytes(res.ContentLength, "Downloading france-latest.osm.pbf")
+ if resuming {
+ // Re-append to the partial file when the server honored the range request.
+ if _, err := file.Seek(offset, io.SeekStart); err != nil {
+ return err
+ }
+ }
+ _, err = io.Copy(io.MultiWriter(file, bar), res.Body)
+ if err != nil {
+ return fmt.Errorf("failed to download %s: %w", francePBFRUL, err)
+ }
+ if size, err := file.Stat(); err == nil && size.Size() != total {
+ return fmt.Errorf("download incomplete: got %d bytes, want %d", size.Size(), total)
+ }
+ return nil
+}
diff --git a/ride/climb.go b/ride/climb.go
index 8ac60fa..d5a1718 100644
--- a/ride/climb.go
+++ b/ride/climb.go
@@ -16,6 +16,7 @@ type Climb struct {
rideStart int
rideEnd int
points []Point
+ Name string
}
func (climb Climb) Duration() time.Duration {
@@ -34,11 +35,27 @@ func (climb Climb) End() Point {
return climb.points[len(climb.points)-1]
}
+// Top returns the highest point inside the climb, used as the crest anchor
+// when naming the climb after a pass.
+func (climb Climb) Top() Point {
+ top := climb.points[0]
+ for _, point := range climb.points {
+ if point.ElevationM > top.ElevationM {
+ top = point
+ }
+ }
+ return top
+}
+
func (climb Climb) String() string {
start := climb.points[0]
end := climb.points[len(climb.points)-1]
score := Score(climb.points, 0, len(climb.points)-1)
- return fmt.Sprintf("%.1fkm-%.1fkm: %.1fkm at %.1f%% (%d pts - %s)", start.DistanceM/1000, end.DistanceM/1000, (end.DistanceM-start.DistanceM)/1000, Slope(start, end)*100, int(score), Category(score))
+ body := fmt.Sprintf("%.1fkm-%.1fkm: %.1fkm at %.1f%% (%d pts - %s)", start.DistanceM/1000, end.DistanceM/1000, (end.DistanceM-start.DistanceM)/1000, Slope(start, end)*100, int(score), Category(score))
+ if climb.Name == "" {
+ return body
+ }
+ return climb.Name + ": " + body
}
func (climb Climb) Score() float64 {
diff --git a/ride/ride.go b/ride/ride.go
index 73e7a26..df5560b 100644
--- a/ride/ride.go
+++ b/ride/ride.go
@@ -31,6 +31,10 @@ func (r *Ride) check() {
kcore.Assert(len(r.points) > 0, "no points in ride")
}
+func (r *Ride) Points() []Point {
+ return r.points
+}
+
type RideParser interface {
Parse(reader io.Reader) (Ride, error)
}