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 --- .gitignore | 2 + README.md | 19 ++- chart.go | 81 ++++++++++ db/migrations/20260802090000_osm_passes.sql | 17 ++ db/schema.sql | 11 +- go.mod | 16 +- go.sum | 41 ++++- main.go | 134 +++++++++++++--- mountain_pass/detection.go | 115 +++++++++++++ mountain_pass/detection_test.go | 109 +++++++++++++ mountain_pass/downloader.go | 61 ++++--- mountain_pass/downloader_test.go | 6 +- osmpass/enrich.go | 240 ++++++++++++++++++++++++++++ osmpass/enrich_test.go | 149 +++++++++++++++++ osmpass/extract.go | 98 ++++++++++++ osmpass/extract_test.go | 49 ++++++ osmpass/fetch.go | 98 ++++++++++++ ride/climb.go | 19 ++- ride/ride.go | 4 + 19 files changed, 1214 insertions(+), 55 deletions(-) create mode 100644 chart.go create mode 100644 db/migrations/20260802090000_osm_passes.sql create mode 100644 mountain_pass/detection.go create mode 100644 mountain_pass/detection_test.go create mode 100644 osmpass/enrich.go create mode 100644 osmpass/enrich_test.go create mode 100644 osmpass/extract.go create mode 100644 osmpass/extract_test.go create mode 100644 osmpass/fetch.go 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) } -- cgit v1.2.3