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.