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
Address ⇄ coordinate down to the house number, plus 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.
More than a solver — a platform for optimization. Beyond routing and VRP, MPEE is programmable and data-aware at every layer: constraints and costs written as code (PySpell — Rust or Python expressions compiled to a sandboxed native AST and run in the hot loop), lossless compression of external matrices (matcodec — bring a matrix from anywhere, store it 7–10× smaller, stream it bigger than RAM, random-access it compressed), and a cost-aware matrix broker that buys only the cells the solver reads from a paid API, derives the rest, never buys the same cell twice, and learns one day of live traffic to replay offline — turning a recurring per-call matrix bill into a one-time skeleton (often ≈ $0 per later run). You assemble an optimizer for your problem — not just call a solver.
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.
One Rust engine, five ways to drive it
MPEE is a Rust engine, not a Python library. Routing, the
streamed matrix, the VRP solver, every constraint and objective — all live
in the brooom and dijeng Rust crates.
Python, the HTTP API, the CLI, and the JSON config are thin
surfaces over that same core, with no feature difference between
them. pip install mpee is PyO3 bindings
compiled from the Rust crate — the exact same native solver, not a
reimplementation (so is the WebAssembly demo). Pick a tab — the example
below switches, and the rest of the page follows it:
Notebooks, glue code, the quickest start — PyO3 bindings over the Rust core.
pip install mpee
from mpee import Router
r = Router("london.pp", "london.ch")
plan = r.optimize(stops, vehicles=10,
objective=["unassigned", "vehicles", "cost"]) # lexicographic
Embed it — lowest latency, native constraint callbacks, compiles to WASM.
// Cargo.toml: brooom = "0.4"
use brooom::solver::{solve, SolverConfig, ObjectiveMode, LexObjective};
let cfg = SolverConfig {
objective_mode: ObjectiveMode::Lexicographic {
levels: vec![LexObjective::Vehicles, LexObjective::Cost],
},
..Default::default()
};
let solution = solve(&mut problem, Some(&matrix), cfg)?;
Expose the solver over HTTP with one flag. Same JSON as the CLI — with sync and async-webhook modes.
# 1. expose the engine
brooom --serve 8088
# 2a. synchronous: POST a problem, get the solution back
curl -X POST http://127.0.0.1:8088/solve --data-binary @problem.json
# 2b. async: add a "webhook" field → 202 {job_id} now, solution POSTed later
curl -X POST http://127.0.0.1:8088/solve \
-d '{"vehicles":[...],"jobs":[...],"webhook":"https://you.app/cb"}'
# → {"job_id":1,"status":"running"} then poll GET /jobs/1, or wait for the callback
Scripts, batch jobs, CI — one self-contained binary, no runtime.
brooom -i problem.json -o solution.json \
--objective lexicographic \
--objective-levels unassigned,vehicles,cost
Drive it from any language — no code. Objectives, dimensions and caps go in an options block (works via CLI, the HTTP API, or Python).
{
"vehicles": [ { "id": 1, "start": {...}, "capacity": [100] } ],
"jobs": [ { "id": 7, "location": {...}, "delivery": [5] } ],
"options": {
"objective": { "levels": ["unassigned", "vehicles", "cost"] },
"dimensions": [ { "name": "fuel", "transit": "distance / 10",
"start": 500, "min": 0, "monotonicity": "non_increasing" } ]
}
}
Save it as problem.json, then load it any of these ways:
# CLI
brooom -i problem.json -o solution.json
# HTTP API (start the server once: brooom --serve 8088)
curl -X POST http://127.0.0.1:8088/solve --data-binary @problem.json
# Python
from mpee import Router
plan = Router("london.pp", "london.ch").solve(open("problem.json").read())
Same task in every tab, same native solver. Advanced features — an N-level lexicographic objective and custom accumulator dimensions (OR-Tools-style fuel/resource tracking) — are reachable identically from all five; see the README.
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 | 1.44 s | streamed | impractical — no chunked many-to-many |
| 50k × 50k | 26.8 s | ≤ 500 MB | OOM — the matrix alone is ~10 GB |
Streaming at fleet scale · bench_matrix, Greater London car, Apple M3 Pro
Same London CH (n = 1.16 M), chunked streaming with a configurable
memory budget (--matrix-budget-mb, default 500 MB). The solver
never materialises the full matrix — peak RAM stays under the budget cap.
| Matrix | Budget | Chunk | Time | Peak RAM | Throughput | Speedup† |
|---|---|---|---|---|---|---|
| 100k × 100k | 250 MB | 97 | 286 s (~5 min) | 295 MB | 34.9M cells/s | 0.45× |
| 100k × 100k | 500 MB | 296 | 128 s | 456 MB | 77.9M cells/s | 1.00× |
| 100k × 100k | 1 GB | 694 | 88 s | 810 MB | 113.7M cells/s | 1.46× |
| 100k × 100k | 2 GB | 1489 | 74 s | 1.5 GB | 135.2M cells/s | 1.73× |
| 100k × 100k | 4 GB | 1500 | 75 s | 1.4 GB | 132.9M cells/s | 1.71× |
| 100k × 100k | 8 GB | 1500 | 89 s | 1.4 GB | 112.3M cells/s | 1.44× |
| 200k × 200k | 500 MB | 150 | 773 s (~13 min) | 531 MB | 51.7M cells/s | 1.00× |
| 200k × 200k | 1 GB | 352 | 500 s (~8 min) | 766 MB | 80.1M cells/s | 1.55× |
†Speedup vs the same N at 500 MB budget (128 s for 100k, 773 s for 200k). 98 % finite cells on every run. Re-measured 2026-06-10 on a CH rebuilt with stall-on-demand + edge-difference ordering — the whole table got 3–5× faster than the previously published runs. Higher budget → larger chunks → faster up to a ceiling (100k: 2–8 GB plateau at chunk 1500 ≈ 74–89 s; 250 MB halves RAM but costs ~2.2× wall-clock). Raw logs: benchmarks/london-scale/.
Single point-to-point queries: MPEE ≈20 µs vs OSRM ≈30 µs internal — stall-on-demand + edge-difference ordering closed what used to be a 3× OSRM lead (the old "honest caveat" here), and CH preprocessing dropped from minutes to 4 s (OSRM: ~37 s). Distances verified exact against full Dijkstra. MPEE still 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 = 100–200 (small) | edges ahead of PyVRP (HGS-class SOTA): same-harness 10 s on the 19 Solomon R2/RC2 wide-window instances, mean Δ −0.14% — at-or-below PyVRP on 15/19 (7 wins incl. r208 −1.4%, 8 exact ties, worst loss +0.29%); also beats it on tight-window rc101 (−0.36%). Was +2–13% — closed by an O(1)-cost-delta local search (4–22× faster cold LS), incremental Split, SREX+OX population HGS and perturbation-local ILS re-convergence. OR-Tools sometimes can't even find a feasible solution in the budget. |
| 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 |
Same instance, same matrix, same budget through every solver — re-run it yourself: crates/brooom/benchmarks (raw 4-way numbers in competitor_comparison.md). PyVRP (HGS) is the small-CVRPTW reference; brooom now edges ahead of it on R2/RC2 (mean −0.14%, at-or-below on 15/19) and beats the field at n≥1000 — with the added edge of an integrated single-engine stack, speed-to-good-answer, and scale.
Where MPEE fits
An engine, not a service. The usual knock — "great router and solver,
but no global coverage" — is backwards. MPEE gives you anywhere,
self-hosted, no per-call API, your data stays put, two
complementary ways: route offline from OpenStreetMap (the whole
planet is an OSM extract — dijeng builds the cache, brooom solves), or
bring your own matrix computed anywhere (a commercial API, an
internal system, another OSRM) — brooom ingests it directly and
matcodec compresses/validates it without the router. At planet
scale the router becomes central: it feeds the compressor row by row,
so a matrix larger than RAM is streamed and compressed without ever
materialising n² — compute → stream → compress → solve.
- SOTA quality, now slightly ahead. Same-harness 10 s on Solomon R2/RC2, MPEE edges ahead of PyVRP (HGS-class SOTA) at mean −0.14% — at-or-below it on 15/19 (7 wins, 8 exact ties, worst loss +0.29%). Of the open solvers we benchmarked it's the only one that reaches PyVRP — VROOM and OR-Tools trail it.
- Wins on total time & scale. One process does routing and optimization, streaming the matrix instead of materialising ~10 GB — so total wall-clock (matrix + solve) beats the OSRM+VROOM split, and it keeps solving where they run out of RAM (50k on a laptop). At N ≥ 1,000 it also wins on quality.
- No manual zoning — submit the whole fleet. Large VRP is usually made tractable by hand-carving the area into territories and solving each separately. MPEE doesn't ask for that: it auto-decomposes internally (cluster-first for large N) and re-polishes across cluster boundaries, so you submit the entire problem and get one optimized plan.
- Graceful degradation — not a dead "infeasible". When no plan can
hit every time window, a hard solver (e.g. PyVRP) just returns
infeasible. With soft time windows MPEE still serves everyone and
reports exactly which stops are late and by how much
(
time_warp,late_jobs,max_lateness, and a per-joblate[]list) — an actionable best-effort plan with the violations made visible, not a dead end. On-time plans report nothing extra.
Bring your own matrix — compress it losslessly · new matcodec crate
Got a matrix computed elsewhere — a whole country, the rest of the world?
matcodec stores it losslessly and far
smaller than a general compressor, by exploiting the structure a road
network leaves in the numbers: where two regions are joined by few roads,
the cross-block is min-plus low rank. It auto-picks the better of two exact
models per matrix (1 header byte, always a byte-identical roundtrip) —
cluster (regional blocks) or bridge (farthest-point
landmarks, base(i,j)=minₗ d(i,l)+d(l,j)):
| Matrix | matcodec (lossless) | compress · decompress |
|---|---|---|
| Real OSRM road, 16 towns (960²) | 9.79× (cluster) | 0.97 s · 0.01 s |
| Real OSRM road, 8 towns (320²) | 6.99× (cluster) | 0.12 s · ~0 s |
| Oslo haversine (1001²) | 4.41× (bridge) | 3.2 s · 0.05 s |
| structureless points | ~1.8× | graceful floor |
More geographic separation ⇒ better ratio; plain gzip ≈ 2× on
the same matrices. Decompression is essentially free (~10 ms), so you can
decompress on the fly.
It also streams (peak memory L×n + 1 row, fed
by dijeng's per-row CH queries — so a matrix bigger than RAM is compressed
without ever materialising n²), gives random access to the
compressed blob in RAM (better than swapping a raw matrix), and
validates each row as it streams — negative / unreachable
cells, and a free triangle-inequality check (a positive bridge
residual is a violation) that auto-disables the metric-only
shortcuts when your matrix isn't a clean metric.
Pay only for the matrix you use — the cost-aware broker · new · cost / delay
The money story. A 400-stop run is an N² = 160,000-element matrix. At a typical per-element API price (~$5 / 1,000 elements, illustrative) that's ~$800 bought naively — every solve; re-plan daily and it's ~$24,000/quarter. The broker buys only the skeleton the solver reads (<50 %, often far less), so the first run is a fraction of that and every later run is ≈ $0 — a warm local cache never buys the same cell twice, and a temporal profile learned on one workday is replayed offline for every similar day. Pay once; reuse forever.
The flip side of compressing a matrix you have is not
buying one you don't. When the matrix must come from a
paid or metered provider — Google Distance Matrix, a billed
OSRM, an internal endpoint — a full N×N is wasteful: the solver only
ever reads each stop's nearest neighbours plus the depot and a few landmarks.
The broker ranks candidates with a free Haversine prior,
buys only that skeleton exactly (no quality loss on what the
search touches), derives every long-range cell with the same
min-plus bridge matcodec uses, and caches it in a local DB —
so a warm DB buys zero cells the second run, and the same
cell is never bought twice. A PySpell broker.* spell prices the
buy; a budget cap keeps the search-critical cells and derives the rest.
Time-of-day, learned once, replayed offline. Travel time
depends on when you leave. The broker can key its cache by a
(weekday-class, hour) window and store a running mean and
variance per cell. Fetch one representative workday's
hourly cells and — because the key is a weekday class, not a date —
that profile answers every weekday at that hour with no new calls.
The variance is the uncertainty: with an uncertainty weight
the matrix cell becomes mean + weight·std, so flaky,
queue-prone arcs cost more and the solver routes around them. Buy a little
live congestion once; reuse it offline for every similar day.
Provider-agnostic (Google is an example, not a dependency); pairs naturally with a compressed offline graph as the free base plus a thin paid “delay” overlay. Absent the broker, routing is byte-for-byte unchanged. How much you save, the four cost levers & every flag →
Real maps, not just synthetic · real OSM road distances, seed-reproducible
The numbers above use the classic synthetic Solomon set. We also benchmark on real road networks: a generator picks delivery addresses inside a real city's bounding box from a seed (so anyone reproduces the exact instance — no data to download), builds the matrix from real OSRM road distances, and runs every engine on it. One run — San Francisco, seed 11, 80 stops, real roads, 10 s:
| Solver | Cost | Δ vs best | Time |
|---|---|---|---|
| brooom (MPEE) | 27648 | +0.06% | 10 s (27 648 already at 6 s) |
| OR-Tools | 28105 | +1.7% | 10 s |
| PyVRP (HGS) | 27632 | +0.0% | 10 s |
Same story on real roads as on Solomon: brooom ties HGS-class quality
(+0.06% — was +2.1% before the time-window-aware HGS gate let the
population search run on real-map instances), and already matches that at a
6 s budget. Reproduce exactly:
python3 gen_realmap.py --region sf --seed 11 --n 80 --matrix osrm --osrm-host <host>
then run_multi_bench.py over the result. Regions: sf, oslo,
london, nyc. brooom can also share OSRM's matrix (--routing osrm)
for a like-for-like comparison.
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.
Self-reported — reproduce & validate it yourself
Honesty first: every number here is self-reported, measured on one machine (Apple M3 Pro), and has not yet been independently validated. So the benchmarks are built to be re-run by anyone — same instances, same matrix, same time budget through every solver:
# competitors (one venv; Python 3.12 has wheels)
python3.12 -m venv .bench-venv && .bench-venv/bin/pip install pyvrp ortools
# synthetic (Solomon) — 4-way, identical budget
.bench-venv/bin/python crates/brooom/benchmarks/run_multi_bench.py \
--time-limit 10 crates/brooom/benchmarks/instances_solomon/r201.json
# real maps — seed-reproducible (no data to download), real OSRM road distances
python3 crates/brooom/benchmarks/gen_realmap.py --region sf --seed 11 --n 80 \
--matrix osrm --osrm-host <host>
.bench-venv/bin/python crates/brooom/benchmarks/run_multi_bench.py instances_realmap/sf_s11_n80_osrm.json
Where we stand, honestly: on raw solution quality we are now slightly
ahead of PyVRP's HGS-class ILS on wide-window small-N (R2/RC2 mean Δ
−0.14 % same-harness, at-or-below on 15/19; single-rep noise is ±~0.3 %), beat
it on tight-window rc101 and tie the rest of C1/R1, and beat OR-Tools + the
field at N≥1000. Where we add edge: the integrated single-engine stack,
memory/scale (50k on a laptop), and end-to-end speed. Raw results + the analysis live in
benchmarks/results/
(competitor_comparison.md, beat_pyvrp.md).
Independent benchmarks welcome — open an issue with your numbers.
Install
One engine, install it for whichever surface you use.
Python
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.
Rust (native crates)
# in your Cargo.toml project
cargo add brooom # the VRP solver (constraints, objectives, soft-TW)
cargo add dijeng # contraction-hierarchy routing + distance matrix
Embed the engine directly — native constraint callbacks, no FFI, compiles
to WASM. brooom is the solver; dijeng is the
routing/matrix engine. See the
crates.
CLI & HTTP API
# build the binary from source (one Rust toolchain needed)
cargo install --git https://github.com/punnerud/mpee brooom
brooom -i problem.json -o solution.json # CLI solve
brooom --serve 8088 # HTTP API on :8088 (POST /solve)
The same brooom binary is the CLI, the HTTP API server, and the
JSON-config runner — pick the surface, the engine is identical. The Python
wheel bundles it too (mpee CLI).
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) — streets & house numbers
mpee reverse 51.5080,-0.1281 --cache data/greater-london-latest.osm.pbf # → Baker Street 221B, NW1 London
mpee geocode "Baker Street 221B" --cache data/greater-london-latest.osm.pbf # → 51.5237,-0.1585
mpee geocode "Baker Street" --cache data/greater-london-latest.osm.pbf # → 51.522072,-0.157497 (street)
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 (the .names + .addr sidecars are built by `mpee build`).
r.reverse(51.5080, -0.1281) # → "Baker Street 221B, NW1 London"
r.geocode("Baker Street 221B") # → {"name","housenumber","lat","lon","city","postcode","approximate"}
r.geocode("Baker Street") # → {"name", "lat", "lon"} (street-level)
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 — streets and house numbers
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.
House numbers too. The build also parses OSM
addr:* — from address nodes, building-polygon centroids, and
addr:interpolation ways — into a separate, deletable
.addr sidecar (its own coordinates + spatial grid, independent
of the road graph). So you can resolve a full address both ways:
# forward: "Street 42" → coordinate (+ city/postcode)
mpee geocode "Städtle 1" --cache liechtenstein.osm.pbf # → 47.1410,9.5215 (Vaduz)
# reverse: coordinate → nearest full address
mpee reverse 47.1410,9.5209 --cache liechtenstein.osm.pbf # → Lettstrasse 2, 9490 Vaduz
# missing number? the nearest existing number on the street, flagged approximate
mpee geocode "Lettstrasse 9999" --cache liechtenstein.osm.pbf # → Lettstrasse 74 (approx)
# Python — geocode("Street 42") "just works"; or pass them apart:
# r.geocode("Städtle 1") → {"name","housenumber","lat","lon","city","postcode","approximate"}
# r.geocode_address("Städtle", "1")
# r.reverse(47.1410, 9.5209) → "Lettstrasse 2, 9490 Vaduz"
Optional and back-compatible: a cache without a .addr still
answers street-level queries exactly as before. A street name shared by
several towns is disambiguated with --near.
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.
The full constraint set — built in
Beyond the table above, every standard VRP variant is first-class — a field or a solve option, no plugins: pickup & delivery (paired, same vehicle), backhaul, driver breaks, multi-trip / reloading, multi-depot, release times, prize-collecting / optional jobs, disjunctions (explicit drop penalty), client-groups (visit one of a set), max-vehicles cap, fairness / load balancing, N-level lexicographic objectives, and custom accumulator dimensions (OR-Tools-style fuel/resource with per-arc transit and soft cumul bounds).
Soft time windows — serve late, don't drop
OR-Tools-style soft bounds: when a stop can't be served inside its window
(or would overload a vehicle, or overrun the shift), MPEE can serve it and
charge a penalty instead of abandoning it. λ is high
and fixed, so it's a strict improvement — on a feasible problem the result
is byte-identical to the hard solve (0.00 % across Solomon C/R/RC),
and on an over-constrained one it serves the stops a hard solver would drop
(a tightened Solomon r101 goes from 2 dropped stops to 0, at 5.3× lower
cost). Auto-enabled when a problem has time windows; one flag to force on/off.
# Python · CLI · JSON — same knob everywhere
r.solve(problem_json, soft_tw=True) # Python
brooom -i problem.json --soft-tw # CLI
{ "options": { "soft_time_windows": true } } # JSON
Code your own constraints — in Rust or Python
This is where MPEE goes past a fixed feature list. Write a constraint as a small function in Rust or Python; MPEE parses it to a sandboxed AST (pyspell) and runs it natively inside the hot evaluation loop — no per-call interpreter, no Python GIL on the path. Per-route rules and cross-route (global) rules both supported.
# A custom per-route rule, written in Python, compiled to native:
# "no route may carry more than 12 fragile parcels"
r.solve(problem_json, constraints=["sum(1 for j in route if j.fragile) <= 12"])
# return a number instead of a bool → a *soft* penalty the search trades off.
Honest scope vs OR-Tools: MPEE does native structured constraint propagation — a sound pre-pass that tightens time windows (from depot travel + shift), closes precedence, and proves unservable jobs up front — so it infers, not just evaluates. What OR-Tools' CP-SAT still does that MPEE does not: general constraint programming with bidirectional propagation over arbitrary logic. For that rare propagation-hard case MPEE ships a CP-SAT bridge (export → solve exactly → warm-start back). What MPEE gives you that OR-Tools doesn't: the same rich built-ins plus code-defined constraints in both Rust and Python, compiled to native, running in one process with the routing engine and the streamed distance matrix — no separate matrix build, no service mesh.
Constraint cookbook
One copy-paste recipe per constraint — the full set is in docs/constraint-cookbook.md (each recipe is backed by a conformance test, so it can't rot). A few of the most-asked:
# Precedence — job 30 before job 10 on the same route (first-class field)
{ "precedence": [[30, 10]], "vehicles": [...], "jobs": [...] }
# Client groups — serve exactly k of N (k-of-N disjunction)
router.solve(problem_json, group_cardinality=(2, 2)) # + "group": 7 on the jobs
# Hard balance — cap the spread of route load (not just a soft nudge)
router.solve(problem_json, balance_spread=0, fairness_metric="load")
# Soft time windows — serve late for a penalty instead of dropping
router.solve(problem_json, soft_tw=True)
# Any rule, in code — parsed to a sandboxed AST, run natively in the hot loop
router.solve(problem_json, constraints=["sum(1 for j in route if j.fragile) <= 12"])
Pick by problem shape: optional stop → prize; choose-one-of-set
→ group; drop-with-penalty → disjunction_penalty;
rank objectives → objective=[...] (lexicographic); track
fuel/battery → dimensions. The cookbook has the decision table
and a runnable JSON for every row of the comparison matrix above.
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.
Vision: rigged for transfer-at-speed
MPEE solves today's fleet routing — and is deliberately built for a future of autonomous cars, buses, trains and boats with seamless transitions: ride A to Å with a car's door-to-door convenience at a bus's cost-efficiency, enabled by transferring between vehicles at speed. A hundred individual cars can never economically compete with one trunk vehicle running 95 % of the distance without a single stop — pods handle the last mile, docking without anyone stopping. At sea the logic is even stronger: a hydrofoil's expensive moment is coming down off its foils, so the big foil never stops — feeder boats dock with it while both are foiling (up and down the Oslofjord, along coastlines) and meet pods and buses at dedicated quays.
Every moving rendezvous is a routing constraint ~100× tighter than a delivery slot, replanned continuously at fleet scale. That is why a single-process engine with fleet-sized matrices in seconds, SOTA solve quality per second and a live-updatable hierarchy is the load-bearing piece. Full write-up: docs/future-vision.md.
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,
objective=None, dimensions=None, soft_tw=None) -> dict
r.solve(problem_json, time_limit_s=5.0, constraints=None, max_vehicles=None,
fairness_weight=0.0, objective=None, dimensions=None, soft_tw=None) -> dict # VROOM-style JSON
# objective: None/"scalar" (default) or a level list, e.g. ["vehicles","cost"]
# (levels: unassigned, vehicles, cost, makespan, distance) — lexicographic
# dimensions: [{"name","transit","start","min","max","monotonicity","soft_max",…}]
# transit is a sandboxed expr over distance/duration/cumul (OR-Tools-style)
# constraints: Rust/Python rules -> parsed to a sandboxed AST, run natively in the
# hot loop (bool => hard reject; number => soft penalty). Per-route + global.
# soft_tw: None=auto (on when there are job time windows) | True | False -- serve a
# stop late / over capacity for a penalty instead of dropping it
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.
Contact
Morten Punnerud-Engelstad
morten@punnerud.net