Local running-route planner for Leipzig. See plans/implementation-plan.md
for the full design; this file just covers day-to-day commands.
Everything in one command:
docker-compose up # GraphHopper + the app -> http://localhost:8000
docker-compose up -d # ...in the background
docker-compose down # stop bothFirst run builds two images and, if the graph volume is empty, imports the OSM extract before the app starts accepting requests — the app waits on GraphHopper's healthcheck rather than racing it. Later starts are seconds.
One-off setup, before the first up (these generate data/, which is
bind-mounted rather than baked into the image and is not in the repo — see
Licensing and attribution):
./scripts/fetch_extract.sh # download + clip the OSM extract
uv run python scripts/extract_transit_stops.py # ~1,900 transit stops
uv run python scripts/extract_features.py # lit/green/water grid (~16s).env is optional and only carries RUN_PLANNER_GOOGLE_MAPS_API_KEY. Without
it transit routes still work; stops just come back marked unverified.
uv sync
uv run pytest
uv run uvicorn run_planner.main:app --reload # expects GraphHopper on :8989docker-compose up graphhopper starts just the engine if you want the app on
the host and the engine in Docker.
GET /health— whether GraphHopper is reachable.GET /config— default start point + distance bounds, used by the frontend.POST /routeswithmode: "loop"— two-stage loop generation: GraphHopperround_tripcandidates, re-ranked in Python. Omitweightsto get the day/night profile (run_planner/profiles.py); supply them to override.POST /routeswithmode: "transit_out"— transit out, run home. Either picks drop-off stops for you (safety-filtered, ranked, Google-verified) or, withstop_id, generates alternative routes home from a station you chose. No 0.82 calibration here — that correctsround_triponly; point-to-point routing returns real distances.POST /routeswithpool_id— add to a previous pool and re-rank the whole accumulated set ("generate more").GET /routes/{id}/gpx— GPX for a candidate from the most recentPOST /routescall (in-process only; ids don't survive an app restart).GET /transit/stops?target_distance_km=10— candidate drop-off stops.GET /transit/stops/search?q=gohlis— station name search, for picking a start station yourself.
The UI asks one question — daylight or dark — and the server turns it into
weights (run_planner/profiles.py):
| paths | green | water | lit | |
|---|---|---|---|---|
| Daylight | 89 % | 50 % | 34 % | 52 % |
| Dark | 24 % | 17 % | 4 % | 96 % |
Measured on 10 km loops from home. Dark is not "daylight with less greenery": it actively prefers roads, because at night the useful property is being lit and overlooked, which a street is and a park path isn't.
The point of transit mode is not getting dropped somewhere isolated. Any stop
whose first 1 km of route home is more than 50% rural by GraphHopper's
urban_density is rejected outright — a hard filter, never a slider. Measured
live: 13 of 40 shortlisted stops rejected for a 15 km target.
This needs urban_density in the graph. If it's missing the API returns a 503
telling you to rebuild, rather than silently returning unfiltered stops.
Two gotchas worth knowing, both found the hard way:
GET /infoadvertises the values asRURAL/RESIDENTIAL/CITYbut/routepath details return them lowercase. Comparing against uppercase makes the filter silently pass everything while looking like it works.- Google needs Routes API in the key's API-restriction list, not just
enabled on the project. Otherwise:
API_KEY_SERVICE_BLOCKED. Without it, routes still generate and are returned markedtransit_verified: false— never as an unverified-but-implied connection.
Use
docker-compose(hyphenated) on this machine. The HomebrewdockerCLI here has no Compose plugin, sodocker compose …fails with a misleadingunknown flagerror. The standalonedocker-composebinary (v2.40, i.e. Compose v2) is installed and works. To use the space-separated form, link the plugin in once:mkdir -p ~/.docker/cli-plugins ln -sfn /opt/homebrew/opt/docker-compose/bin/docker-compose \ ~/.docker/cli-plugins/docker-compose
docker-compose up handles the engine — the notes below are for when
something needs poking at directly.
- The graph is version-tied. Bumping
GRAPHHOPPER_VERSIONingraphhopper/Dockerfilemeansdocker-compose down -vso it rebuilds. Same after changinggraph.encoded_values. - Config edits don't need
--build.graphhopper/config.ymlandcustom_models/are bind-mounted over the copies baked into the image. - A clean import log for debugging encoded values or custom models:
docker-compose --profile import up— builds the graph and exits. Not a required step; the serve container doesimportOrLoad()itself. graph-cacheandelevation-cacheare named volumes, not host directories — bind-mounting them made the daemon try to chown through macOS file sharing and fail. Inspect withdocker volume ls.
These are called out in plans/implementation-plan.md and can't be checked
without actually running the container:
- Whether GraphHopper accepts
foot_networkandlitas encoded values (both included speculatively ingraphhopper/config.yml— GraphHopper logs unknown ones clearly at startup; iflitis rejected, that answers the plan's open question and Stage 2 Python scoring covers lighting either way). - Actual memory footprint of import and serve for the clipped extract —
determines whether landmark preparation (
profiles_lm, currently empty/ disabled ingraphhopper/config.yml) is affordable later. - Round-trip quality at the 18–22 km end.
- Whether local SRTM resolution is adequate — Leipzig is flat enough that elevation weighting may be near-noise.
The code in this repository is MIT-licensed — see LICENSE.
Map data is not. Everything under data/ is derived from OpenStreetMap
and is therefore a Derivative Database under
ODbL 1.0, which the MIT
license does not and cannot cover. That is why data/ is gitignored rather
than committed: you generate it yourself from Geofabrik with the setup scripts
above, and whatever you do with the result is between you and the ODbL. If you
redistribute a route, a GPX file, or the derived grids, you owe attribution to
© OpenStreetMap contributors and share-alike on the data.
Map tiles. The frontend defaults to the OSM Foundation's raster tiles at
tile.openstreetmap.org. Those are a donated community resource governed by
the Tile Usage Policy,
which rules out heavy automated use and commercial use. Personal route
planning is squarely fine; if you fork this into anything with real traffic,
point app.js at your own tile server or a commercial provider. The
"© OpenStreetMap contributors" credit rendered on the map is a licence
condition, not decoration — leave it in place.
Third-party components, none of them vendored into this repo:
| Component | License |
|---|---|
| GraphHopper 11.0 (separate container, HTTP only) | Apache-2.0 |
| MapLibre GL JS 4.7.1 (CDN) | BSD-3-Clause |
| FastAPI, pydantic, pydantic-settings, pytest | MIT |
| httpx, uvicorn, starlette | BSD-3-Clause |
| pyosmium | BSD-2-Clause |
| gpxpy | Apache-2.0 |
| IBM Plex, via Google Fonts | OFL-1.1 |