Track progress cycling every bikeable street in Brighton & Hove.
- Fetch the OpenStreetMap bike network into PostGIS.
- Map-match recorded GPX rides to the network with HMM + Viterbi
(via
leuvenmapmatching), globally consistent, avoids parallel-road hopping. - Visualise cumulative coverage (done / partial / untouched).
- Auto-generate ~20 km Chinese-Postman loops over the streets you still haven't ridden.
Current real network: ~13,179 edges / ~988 km of bikeable ways (after
excluding trunk roads, bicycle=no segments, and small dead-end impasses).
Inspired by Vinayak Mehta's talk "Running every street in Paris with Python and PostGIS" (running-every-street-talk). This is an independent rewrite for cycling in Brighton and Hove.
src/cycling_brighton/(all logic, importable + unit-tested):db(engine/schema/PostGIS),network(osmnx fetch to DB rows),gpx(parse/clean),matching(HMM+Viterbi),coverage(per-edge % + stats),routing(CPP loop generation),viz(folium maps),cli(Makefile-driven commands).notebooks/(thin orchestration + visual output only).- Storage: PostGIS. Matching + geometry in EPSG:27700 (British National Grid, metres); a WGS84 copy is kept for folium.
Uses Postgres.app (PostgreSQL 15), which bundles PostGIS 3.3 (no separate PostGIS install needed). It runs on the default port 5432.
-- once, against your server
CREATE DATABASE cycling_brighton;
\c cycling_brighton
CREATE EXTENSION postgis;Postgres.app binaries live at:
/Applications/Postgres.app/Contents/Versions/latest/bin
Port 5432 gotcha: Homebrew postgresql@14 / @16 will fight Postgres.app
for 5432. Free it with:
brew services stop postgresql@14 postgresql@16DATABASE_URL=postgresql://<user>@localhost:5432/cycling_brighton
# plan-ride defaults (home start point + target uncovered km per loop)
HOME_LAT=50.8225
HOME_LON=-0.1372
TARGET_UNCOVERED_KM=14Config keys:
DATABASE_URL: PostGIS connection string.HOME_LAT/HOME_LON: default loop start (your home), used byplan-ridewhen--startis not given.TARGET_UNCOVERED_KM: default amount of uncovered street to include per loop, used byplan-ridewhen--kmis not given (~14 km uncovered rides ~20 km total after CPP deadheading).
uv venv .venv --python 3.11
uv pip install -e ".[dev]"The make targets wrap the CLI (.venv/bin/python -m cycling_brighton.cli)
so you never need to open Jupyter for the day-to-day loop. They read .env
for the DB and defaults.
| Target | What it does |
|---|---|
make add-ride |
Import GPX ride(s), map-match, update coverage, record the ride. |
make plan-ride |
Grow an uncovered subgraph near home, solve the CPP, export a loop GPX. |
make status |
Print overall coverage (km done / total, streets done) and ride stats. |
make map |
Render the coverage map to coverage.html and open it. |
make test |
Run the test suite with .env loaded. |
Drop one or more ride GPX files into data/gpx/, then:
make add-rideIt imports every *.gpx in data/gpx/, skipping any already imported (rides
are deduped by content hash in the rides table, so re-running is safe and
prints skip (already imported)). For a one-off file outside that folder:
make add-ride file=/path/to/ride.gpxAfter importing, it prints the new overall coverage %.
make plan-rideBy default plan-ride is interactive: it prompts for start (lat,lon),
target uncovered km, and output filename, each seeded with a default from
.env (press Enter to accept). Supplying --start, --km, --filename, or
--yes skips the matching prompts:
.venv/bin/python -m cycling_brighton.cli plan-ride \
--start 50.8225,-0.1372 --km 14 --yes --filename loop_14km.gpxThe loop GPX is written to data/routes/ (load it into your bike computer,
Komoot, or Strava). If your start point sits in a disconnected pocket of the
network, plan-ride prints a note that the ride starts some distance away on
the main network.
The notebooks mirror the CLI for interactive/visual work:
01_build_network.ipynb: geocode Brighton & Hove, fetch the osmnxnetwork_type='bike'graph, write nodes + edges to PostGIS. Caches the graph todata/network.graphmlso you don't re-fetch OSM every session. One-time / refresh.02_track_coverage.ipynb: drop a ride GPX intodata/gpx/, parse it, map-match, apply matches to thecoveragetable, then see overall progress and the coverage map (same path asmake add-ride+make map).03_plan_ride.ipynb: sethome+target_km, grow an uncovered subgraph near home, solve the CPP, and export a loop GPX (same asmake plan-ride).
match_track(max_dist=30): max GPS-to-edge candidate distance (m); raise for noisier tracks.match_track(obs_noise=10): GPS emission noise (m) in the HMM.cpp_route(target)viatarget_km: desired loop length (~20 km). Covering ~13 to 15 km of uncovered street means riding ~20 km total (CPP deadheading).cpp_route(max_odd_nodes=400): safety cap on the O(k squared) odd-node matching; exceeding it raises a clear error asking you to shrink the target/subgraph.
make test # runs the suite with .env loaded (includes DB tests)
.venv/bin/pytest # unit tests only; DB integration tests auto-skipThe DB integration tests auto-skip when DATABASE_URL is unset (no Postgres
needed in CI; no live OSM fetch in tests).
All gitignored (except .gitkeep):
data/gpx/: your recorded rides (input).data/routes/: generated loop GPX (output).data/network.graphml: osmnx graph cache.