MPEE · Rust routing engine

Offline route calculations & optimization

One Rust engine that replaces the OSRM + VROOMOSRM + PyVRPOSRM + OR-ToolsValhalla + VROOMGraphHopper + OR-ToolsGoogle Distance Matrix + OR-ToolsOSRM + VROOM — and equivalently OSRM + PyVRP, OSRM + OR-Tools, Valhalla + VROOM, GraphHopper + OR-Tools, or Google Distance Matrix + OR-Tools stack — routing and vehicle-routing optimization in a single process. Download one area once, then route, optimize, and geocode it fully offline.

A 50,000-customer fleet needs a 50,000 × 50,000 distance matrix — ~10 GB to hold in memory, which is what OSRM + VROOM do. MPEE never materialises it: it streams that matrix through a ~500 MB budget (≈20× less) yet still solves the whole fleet — in 27 s where OSRM runs out of RAM, on CPU + GPU (Metal on Mac).

PyPI version of mpee Supported Python versions MIT license Source on GitHub
▶ Live demo Quick start GitHub

One Rust engine — Python is just one of five equal front doors. See every surface ↓

≤ 500 MB
streams the 50k×50k matrix — never materialises the ~10 GB (OSRM + VROOM), ≈20× less RAM
27 s
to build that 50k² matrix — OSRM runs out of memory
CPU + GPU
GPU-accelerated VRP search (Metal on Mac)
50,000
-customer VRP on a laptop, fully offline
MPEE live demo: address search, street-crossing lookup, point-to-point routing and multi-vehicle optimization over San Francisco, all running in the browser ▶ Live demo — address search, street crossings, routing & multi-vehicle optimization, all in your browser (WebAssembly + WebGPU).
Recorded at real-time speed; the multi-vehicle optimization over 50+ locations comes near the end of the clip.

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)

MatrixMPEE — timeMPEE — peak RAMOSRM
10k × 10k1.44 sstreamedimpractical — no chunked many-to-many
50k × 50k26.8 s≤ 500 MBOOM — 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.

MatrixBudgetChunkTimePeak RAMThroughputSpeedup
100k × 100k250 MB97286 s (~5 min)295 MB34.9M cells/s0.45×
100k × 100k500 MB296128 s456 MB77.9M cells/s1.00×
100k × 100k1 GB69488 s810 MB113.7M cells/s1.46×
100k × 100k2 GB148974 s1.5 GB135.2M cells/s1.73×
100k × 100k4 GB150075 s1.4 GB132.9M cells/s1.71×
100k × 100k8 GB150089 s1.4 GB112.3M cells/s1.44×
200k × 200k500 MB150773 s (~13 min)531 MB51.7M cells/s1.00×
200k × 200k1 GB352500 s (~8 min)766 MB80.1M cells/s1.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

ScaleResult
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,000beats the next-best solver (PyVRP) on 17 / 20 seeds, p ≈ 10⁻⁷
N = 50,000the only tested solver that converges on a laptop
Inner loopthe 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.

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)):

Matrixmatcodec (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 = 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:

SolverCostΔ vs bestTime
brooom (MPEE)27648+0.06%10 s (27 648 already at 6 s)
OR-Tools28105+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-style solve(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 vehiclePer stop
capacity (multi-dimensional)delivery / pickup (package sizes / weights)
skills — which jobs it may serveskills — required vehicle capability
speed_factor — e.g. a motorcycle at 1.6time_windows — allowed arrival times
time_window — the driver's shiftservice — time spent at the stop
max_travel_time / max_distancepriority — 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)

FilePurposeNeeded by
.ppPreprocessed graph + coordinateseverything (snap, geocode, route)
.chContraction-hierarchy shortcutsroute / optimize / solve / table
.namesStreet names + per-street nodesreverse / 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

Contact

Morten Punnerud-Engelstad
morten@punnerud.net