Skip to content

Repository files navigation

Run Planner

Local running-route planner for Leipzig. See plans/implementation-plan.md for the full design; this file just covers day-to-day commands.

Running it

Everything in one command:

docker-compose up          # GraphHopper + the app -> http://localhost:8000
docker-compose up -d       # ...in the background
docker-compose down        # stop both

First 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.

Developing without containers

uv sync
uv run pytest
uv run uvicorn run_planner.main:app --reload       # expects GraphHopper on :8989

docker-compose up graphhopper starts just the engine if you want the app on the host and the engine in Docker.

API

  • GET /health — whether GraphHopper is reachable.
  • GET /config — default start point + distance bounds, used by the frontend.
  • POST /routes with mode: "loop" — two-stage loop generation: GraphHopper round_trip candidates, re-ranked in Python. Omit weights to get the day/night profile (run_planner/profiles.py); supply them to override.
  • POST /routes with mode: "transit_out" — transit out, run home. Either picks drop-off stops for you (safety-filtered, ranked, Google-verified) or, with stop_id, generates alternative routes home from a station you chose. No 0.82 calibration here — that corrects round_trip only; point-to-point routing returns real distances.
  • POST /routes with pool_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 recent POST /routes call (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.

Day / night profiles

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.

Transit stop safety filter

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 /info advertises the values as RURAL/RESIDENTIAL/CITY but /route path 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 marked transit_verified: false — never as an unverified-but-implied connection.

GraphHopper

Use docker-compose (hyphenated) on this machine. The Homebrew docker CLI here has no Compose plugin, so docker compose … fails with a misleading unknown flag error. The standalone docker-compose binary (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_VERSION in graphhopper/Dockerfile means docker-compose down -v so it rebuilds. Same after changing graph.encoded_values.
  • Config edits don't need --build. graphhopper/config.yml and custom_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 does importOrLoad() itself.
  • graph-cache and elevation-cache are named volumes, not host directories — bind-mounting them made the daemon try to chown through macOS file sharing and fail. Inspect with docker volume ls.

Open items to verify by hand once you run the above

These are called out in plans/implementation-plan.md and can't be checked without actually running the container:

  • Whether GraphHopper accepts foot_network and lit as encoded values (both included speculatively in graphhopper/config.yml — GraphHopper logs unknown ones clearly at startup; if lit is 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 in graphhopper/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.

Licensing and attribution

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

About

A small app to plan my runs

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages