What it is
MPEE is built from two Rust crates — dijeng (contraction-hierarchy routing) and brooom (the VRP solver). Together they solve a 50,000-customer vehicle-routing problem on a laptop without ever materialising a full distance matrix, and in head-to-head tests on a Mac they produced shorter routes than VROOM at equal runtime, using less memory. The engine binary is under ~50 MB; the map cache scales with the area you download.
Point-to-point routes
Driving distance + time between two coordinates, with optional geometry.
Multi-vehicle VRP
Best routes for N vehicles over your own stops — capacities, skills, time windows, mixed fleets.
Offline geocoding
Street ⇄ coordinate and street-crossing lookup, reusing the same cache. No separate index.
Bring your own area
Any OpenStreetMap extract sized to where you operate. One downloaded area, one cache.
Scope: MPEE covers a single downloaded area — it is not a route-anywhere-on-Earth offline map. There is no global tiling, by design: within your area, one cache is simpler and faster.
How it compares (measured, Apple M3 Pro)
A 50,000-customer fleet implies a 50,000 × 50,000 distance matrix — ~10 GB if you store it. The classic OSRM + VROOM split builds and ships that matrix between two processes; MPEE streams it in one process and never materialises it — which is where the speed and memory wins come from.
Routing — N×N duration+distance matrix · dijeng vs OSRM, Greater London CH (n = 1.16 M)
| Matrix | MPEE — time | MPEE — peak RAM | OSRM |
|---|---|---|---|
| 10k × 10k | 4.3 s | streamed | impractical — no chunked many-to-many |
| 50k × 50k | 94 s | ≤ 500 MB | OOM — the matrix alone is ~10 GB |
Honest caveat: OSRM is ~3× faster on a single point-to-point query (≈30 µs vs ≈88 µs). MPEE wins decisively the moment you need a fleet-sized matrix — the case VRP actually requires.
Optimisation — VRP solver · brooom vs PyVRP / VROOM / OR-Tools, Solomon-style
| Scale | Result |
|---|---|
| N = 1,000 | beats the next-best solver (PyVRP) on 17 / 20 seeds, p ≈ 10⁻⁷ |
| N = 50,000 | the only tested solver that converges on a laptop |
| Inner loop | the entire local search (2-opt, relocate, swap-star, Or-opt, ILS-kick, regret-3) as one GPU megakernel — Metal on Mac, Vulkan/DX12 elsewhere; sub-ms per iteration |
End-to-end on this machine: 2,000 jobs / 50 vehicles in ~2 min (matrix 0.32 s); 5,000 / 100 in ~9 min (matrix 4.10 s), both ≥99 % assigned. Full numbers in the dijeng and brooom READMEs.
Install
pip install mpee
Prebuilt wheel for macOS (arm64); Linux/Windows install from the source distribution (needs a Rust toolchain) until multi-platform wheels are published. Python 3.8+. No Rust toolchain needed for the macOS wheel.
Quick start (CLI)
1. Install & get a map once
# MPEE — offline routing, VRP & street geocoding for one downloaded area.
# Docs: https://punnerud.github.io/mpee/ Source: https://github.com/punnerud/mpee
pip install mpee
# Download an OpenStreetMap extract (Geofabrik) …
mpee download europe/great-britain/england/greater-london
# … and preprocess it into a routable cache (.pp + .ch + .names).
mpee build data/greater-london-latest.osm.pbf # car (default)
# mpee build data/greater-london-latest.osm.pbf bicycle # or: car | bicycle | foot
After this you are fully offline. The cache is reusable; a re-run reuses it instantly (--force rebuilds, --quiet hushes progress).
2. Route from A to B
mpee route 51.5080,-0.1281 51.5138,-0.0984 --cache data/greater-london-latest.osm.pbf
# distance: 2.38 km
# duration: 4.4 min
3. Optimize a delivery run over many stops
# stops.txt: one "lat,lon" per line (or a JSON [[lat,lon], …])
mpee optimize --stops stops.txt --vehicles 5 --capacity 20 \
--cache data/greater-london-latest.osm.pbf
# stops: 50 vehicles used: 3/5 unassigned: 0
# total: 60.0 km, 115 min (solved in 4.6s)
4. Geocode within the area (offline)
mpee reverse 51.5080,-0.1281 --cache data/greater-london-latest.osm.pbf # → Trafalgar Square
mpee geocode "Baker Street" --cache data/greater-london-latest.osm.pbf # → 51.522072,-0.157497
mpee crossing "Oxford Street" "Regent Street" --cache ... # → Oxford Circus (LAT,LON)
Use it from Python
Coordinate order. The simple helpers take
(lat, lon). The VROOM-stylesolve(problem)accepts a coordinate as{"lat": …, "lon": …}(recommended),[lon, lat], or{"coord": [lon, lat]}.
import mpee
# Open a prebuilt cache (built once via `mpee build`).
r = mpee.Router("data/greater-london-latest.osm.pbf.pp",
"data/greater-london-latest.osm.pbf.ch")
# Distance + time between two points.
leg = r.route(51.5080, -0.1281, 51.5138, -0.0984)
print(leg["distance_km"], "km,", leg["duration_min"], "min")
# Optimize 50 deliveries across 5 vehicles.
stops = [(51.51, -0.12), (51.49, -0.10)] # your (lat, lon) list
plan = r.optimize(stops, vehicles=5, capacity=20, time_limit_s=5.0)
# Geocoding (needs the .names sidecar built by `mpee build`).
r.reverse(51.5080, -0.1281) # → "Trafalgar Square"
r.geocode("Baker Street") # → {"name", "lat", "lon"}
r.intersection("Oxford Street", "Regent Street") # → [{"lat", "lon"}, …]
# Other helpers: r.snap(lat, lon), r.table(stops), r.bbox(),
# r.has_names(), r.has_routing()
Build a cache straight from Python with
mpee.Router.build("area.osm.pbf", profile="car") — it reuses
an existing cache and returns instantly unless you pass force=True.
Geocoding without a separate index
Street names live on the OpenStreetMap road, so MPEE attaches them to the
road nodes during the build and writes a small, deletable
.names sidecar. Reverse reuses the routing
snap grid; forward scans the area's distinct street names;
crossing is the set intersection of two streets' node
lists. Street-level (street + coordinate), not house numbers.
Lightweight: geocoding needs no .ch
Open with the .pp alone to skip loading the largest cache file:
r = mpee.Router("area.osm.pbf.pp") # geocoding-only (no ch_path)
r.geocode("Baker Street") # works
r.has_routing() # → False (route/optimize need a .ch)
# CLI equivalent:
mpee geocode "Baker Street" --pp area.osm.pbf.pp
Multi-city caches: disambiguate with --near
Street names are unique only within a downloaded area. On a country cache the same name exists in several towns, so add a reference point:
# nearest "Munkegata" to a point (Trondheim, not an arbitrary first hit)
mpee geocode "Munkegata" --near 63.43,10.40 --cache norway.osm.pbf
# crossings near a point, within a radius
mpee crossing "Prinsens gate" "Kongens gate" --near 63.43,10.40 --radius-km 5 --cache norway.osm.pbf
# Python: r.geocode("Munkegata", near=(63.43, 10.40))
# r.intersection("Prinsens gate", "Kongens gate", near=(63.43, 10.40), radius_km=5)
Real fleets: per-vehicle & per-stop constraints
For mixed fleets and constrained jobs, use Router.solve(problem)
with a VROOM-style JSON
problem. It exposes the engine's full model:
| Per vehicle | Per stop |
|---|---|
capacity (multi-dimensional) | delivery / pickup (package sizes / weights) |
skills — which jobs it may serve | skills — required vehicle capability |
speed_factor — e.g. a motorcycle at 1.6 | time_windows — allowed arrival times |
time_window — the driver's shift | service — time spent at the stop |
max_travel_time / max_distance | priority — which jobs to keep when demand > capacity |
distinct start / end locations |
solve() serves every job it feasibly can and returns the rest
in unassigned (over capacity, outside all time windows, or no
road to them) — it never invents an impossible route. Model a multi-day
work week by giving each driver one vehicle per day bound to that day's
shift window.
How it works
pip install mpee ships a compiled Rust extension plus a thin
Python CLI. All routing and optimization run in-process;
the map cache is memory-mapped, so opening it is near-instant and peak RAM
stays low. Nothing is sent to a server.
Cache files (built next to your .osm.pbf)
| File | Purpose | Needed by |
|---|---|---|
.pp | Preprocessed graph + coordinates | everything (snap, geocode, route) |
.ch | Contraction-hierarchy shortcuts | route / optimize / solve / table |
.names | Street names + per-street nodes | reverse / geocode / crossing (deletable) |
Cache size scales with map area: a city ≈ tens of MB, a whole country ≈ gigabytes. Download the area you operate in — not the whole world.
Quick reference (for agents & LLMs)
A compact, copy-friendly summary of the whole API surface.
Install & build
pip install mpee # Python 3.8+, macOS wheel / sdist elsewhere
mpee download <geofabrik/slug> # fetch an OSM .osm.pbf extract
mpee build <area.osm.pbf> [car|bicycle|foot] # → area.osm.pbf.pp + .ch + .names
# flags: --force (rebuild), --quiet (hush), --keep-csr (keep parse cache)
CLI verbs (the `mpee` command; --cache PREFIX = the .osm.pbf path)
mpee route LAT,LON LAT,LON --cache <pbf> # distance + time
mpee optimize --stops F --vehicles N --capacity C --cache <pbf> # VRP
mpee reverse LAT,LON --cache <pbf> # coord → nearest street
mpee geocode "Street" --cache <pbf> # street → LAT,LON (+ --near LAT,LON)
mpee crossing "A" "B" --cache <pbf> # where two streets cross (+ --near, --radius-km)
# geocoding verbs accept --pp <file> alone (no .ch needed)
# add --json to route/optimize/reverse/geocode/crossing for scriptable output
Python API
import mpee
r = mpee.Router(pp_path, ch_path=None) # ch_path=None → geocoding-only (skips .ch)
mpee.Router.build(pbf, profile="car", progress=True, force=False) -> dict
# routing (need a .ch):
r.route(from_lat, from_lon, to_lat, to_lon, geometry=False) -> dict
r.optimize(stops, vehicles=1, capacity=1_000_000, depot=None, time_limit_s=5.0) -> dict
r.solve(problem_json, time_limit_s=5.0) -> dict # VROOM-style JSON
r.table(points) -> dict # N×N durations + distances
r.snap(lat, lon) -> (lat, lon)
r.bbox() -> dict
# geocoding (need a .names sidecar):
r.reverse(lat, lon) -> str | None
r.geocode(query, near=None) -> {"name","lat","lon"} | None
r.intersection(a, b, near=None, radius_km=None) -> [{"lat","lon"}, …]
r.has_names() -> bool # geocoding available
r.has_routing() -> bool # routing available (a .ch was loaded)
Facts
- Coordinates: simple helpers use (lat, lon);
solve()accepts{"lat","lon"}/[lon,lat]/{"coord":[lon,lat]}. - Profiles:
car(default),bicycle,foot. - Area-based & offline: download one OSM area; engine binary < ~50 MB; cache scales with area. No global tiling by design.
- Geocoding is street-level (street + coordinate), not house numbers; no separate index — it reuses the routing snap grid + a
.namessidecar. - Multi-city caches: same street name repeats across towns — pass
near=(lat,lon)/--near LAT,LONto disambiguate. - License: MIT. Source: github.com/punnerud/mpee. Package: pypi.org/project/mpee.