Dynamic Vehicle Routing Optimizer
Multi-stop fleet routing that re-plans around rush orders, traffic, and delays instead of freezing tomorrow's route tonight.
At a glance
- Incremental re-optimization on new orders and delays
- OSRM-backed travel times on real road networks
- Capacity and time-window constraint handling
- Redis caching for repeated cost-matrix queries
- Containerized service with PostgreSQL state
From the repository
README
🚚 Dynamic Multi-Stop Vehicle Routing Optimizer
Real-time Capacitated Vehicle Routing with Time Windows (CVRPTW) and in-transit rerouting. Hard time windows · Pinned stops · Degraded-mode routing · Invariant-checked plans
📖 Overview
Planning a city fleet is an NP-hard problem. Most routing demos stop at "here is an optimal route for tomorrow morning." Real operations don't stop — a rush order lands at 11:40, a truck sits in traffic, a customer cancels.
This project implements a production-shaped CVRPTW engine where the plan is expected to survive contact with reality:
- A Google OR-Tools guided-local-search solver plans multi-stop routes under hard time windows, payload capacity and driver shift limits.
- A dynamic reroute engine re-solves while vehicles are moving, pinning already-visited and in-transit stops as immutable.
- Every plan — batch or dynamic — is re-validated against four mathematical invariants before it leaves the process. A violated plan raises; it never ships.
- The service is built to degrade instead of fail: no OSRM? A synthetic asymmetric road matrix. No Redis or Postgres? In-memory stores, not HTTP 500s.
Scope note. This is an optimizer service and simulation harness, not a dispatching UI. It exposes a REST API, a route benchmark simulator, and a PostGIS schema ready for a map layer.
✨ Key Features
| 🧮 Exact CVRPTW solver | OR-Tools RoutingModel with PATH_CHEAPEST_ARC first solution and guided local search, under a configurable time limit. |
| ⏱️ Hard time windows | Every stop carries [earliest, latest]; the Time dimension sets a hard range per cumul variable. Arrival before the window is allowed and paid for as wait time. |
| 📦 Capacity & shift limits | Payload (kg) enforced as an OR-Tools capacity dimension, plus a per-vehicle max_shift_seconds bound on End − Start. |
| 🚨 In-transit rerouting | Ad-hoc urgent orders and cancellations re-optimize only the uncommitted tail, under a 2.5 s budget. |
| 📌 Pinned stops | Visited and in-transit stops are immutable — a reroute can never move a truck that already made its stop. |
| 🛡️ Invariant gate | Payload, hard time window, shift duration and depot start/end are re-checked on every plan (internal/domain/constraints.py). |
| 🌐 Real road matrices | OSRM /table/v1/driving with a SHA-256-keyed Redis cache (24 h TTL) layered over real maps. |
| 🧱 Degraded mode | OSRM timeout → synthetic asymmetric road matrix. Redis/Postgres down → in-memory stores. |
| 🗺️ GeoJSON output | GET /api/v1/routes/{id} returns a FeatureCollection with a LineString per vehicle and a Point per stop. |
| 📊 Benchmark harness | 15-vehicle / 120-stop city fleet with a nearest-neighbour baseline and rush-order injection. |
🏗️ System Architecture
Component breakdown
| Component | Responsibility | Source |
|---|---|---|
| Optimizer Service | FastAPI app; mounts /api/v1/orders and /api/v1/routes routers, exposes /health. Startup DB connect is best-effort. | cmd/optimizer_service/main.py:37 |
| CVRPTW Solver | Builds RoutingModel: distance arc cost, Capacity dimension (demands scaled ×100), Time dimension with per-node hard ranges, per-vehicle shift bound, and drop disjunctions. | internal/solver/vrp_solver.py:77 |
| Reroute Engine | insert_ad_hoc_order rebuilds a sub-problem from remaining capacity and earliest departure; stitches committed legs with newly solved legs and re-times them. cancel_order bypasses a stop and recompresses the schedule. | internal/dynamic/reroute_engine.py:109 |
| OSRM Client | Cache-first matrix fetch. Marks the server unavailable after one failure to avoid repeated timeouts, then falls back permanently to the synthetic matrix. | internal/matrix/osrm_client.py:47 |
| Constraint Validator | Independent re-check of the four invariants; returns (is_valid, violations). Raises ConstraintViolationError-shaped failures upstream. | internal/domain/constraints.py:19 |
| Route Repository | Persists each vehicle route and its stops in a transaction; always keeps an in-memory copy so reads survive a DB outage. | internal/repository/route_repo.py:46 |
| Matrix Cache | Redis GET/SETEX with SHA-256 keys under vrp:matrix:driving:*; falls back to a process-local dict. | internal/matrix/distance_cache.py:47 |
| OSRM Routing Engine | osrm/osrm-backend with the MLD algorithm; enters standby if no .osrm extract is present in ./osrm-data. | docker-compose.yml:35 |
| PostgreSQL + PostGIS | depots, fleet_vehicles, delivery_orders, routes, route_stops; GIST index on order geometry. | migrations/001_init_logistics_schema.sql:1 |
Trust & resilience boundaries
- Process boundary — everything in the optimizer process shares one trust domain and one Python heap. The active plan and problem are held in module-level dicts keyed by
plan_id, so this service is single-replica by design. - Infra boundary — PostGIS, Redis and OSRM are separate containers reached only over the Compose network; each has a healthcheck and its own degradation path.
- Invariant boundary — the
ConstraintValidatoris the only component allowed to declare a plan trustworthy. It sits after both the batch solver and the reroute engine, so a merged dynamic plan is validated as a whole, not just its new legs.
🔁 Core Workflow — POST /api/v1/routes/optimize
Step by step
- Intake —
POST /api/v1/routes/optimizeaccepts a validatedOptimizationProblem: oneDepot, NVehicleConstraints, MWaypoints. Pydantic rejects an inverted time window at the edge (start > end). - Matrix resolution — coordinates are hashed to a
vrp:matrix:driving:<sha256>key. A Redis hit short-circuits the network; a miss queries OSRM; an OSRM failure permanently flips the client to the synthetic matrix for the process lifetime. - Model construction — node
0is the depot; every vehicle starts and ends there. Demands scale ×100 for integer precision. TheTimedimension getsslack_max = 86400(waiting is allowed) but each stop's cumul variable is range-set to its window (late is not). - Search —
PATH_CHEAPEST_ARCseeds the first solution, guided local search improves it withintime_limit_seconds(default 5 s). - Disjunction handling — every waypoint carries a drop disjunction with a
1,000,000penalty, so a stop that cannot fit is returned inunassigned_waypointsand the plan status becomesUNASSIGNABLE_DUE_TO_CONSTRAINTSrather than failing outright. - The invariant gate — the assembled plan is re-checked for payload, hard time window, shift duration and depot start/end. A violation raises
ValueErrorand no plan is returned. - Persistence —
save_planwrites each vehicle route in a transaction while retaining an in-memory copy, soGET /api/v1/routes/{id}keeps working if Postgres is down. - Fan-out — a
ROUTE_UPDATEDevent is published to Redis pub/sub, and the plan plus its problem are cached in-process to enable subsequent reroute calls.
🔀 Dynamic Reroute — the insert path
Why this is the hard part. A reroute cannot simply re-solve the whole problem: the truck has already driven somewhere. The engine therefore treats visited and in-transit legs as fixed, derives each vehicle's remaining payload and earliest departure from its last committed stop, solves only the open tail, stitches the two halves back together, and re-validates the merged result as a single plan.
🧰 Tech Stack
| Layer | Technology | Version | Role |
|---|---|---|---|
| Language | Python | >=3.11, <3.12 | Runtime (pinned for OR-Tools wheels) |
| API | FastAPI | >=0.110 | REST layer, Pydantic v2 validation, lifespan |
| ASGI | Uvicorn | >=0.28 | Server |
| Solver | Google OR-Tools | >=9.9 | CVRPTW routing model + guided local search |
| Validation | Pydantic | >=2.6 | Domain model invariants at the boundary |
| Database | PostgreSQL + PostGIS | 16 / 3.4 | asyncpg pool, geospatial order geometry |
| Cache / Bus | Redis | 7 | Matrix cache (24 h TTL) + pub/sub events |
| Routing | OSRM Backend | latest | Real road distance & duration matrices (MLD) |
| HTTP client | requests, httpx | >=2.31, >=0.27 | OSRM table calls |
| Testing | pytest, pytest-asyncio | >=8.0, >=0.23 | 6 test modules, asyncio_mode = auto |
| Packaging | Hatchling | — | pyproject.toml build backend |
| DevOps | Docker / Compose, Make | — | 4-service stack, make targets |
📁 Project Structure
.
├── cmd/ # Entry points (composition roots)
│ ├── optimizer_service/main.py # FastAPI app, routers, lifespan, /health
│ └── fleet_tracker/main.py # Driver-telemetry runner (ETATracker host)
│
├── internal/ # All business logic (Go-style layout)
│ ├── domain/ # Pure domain — no I/O
│ │ ├── models.py # Depot, Waypoint, VehicleConstraints, RoutePlan
│ │ └── constraints.py # ConstraintValidator: the 4 hard invariants
│ ├── solver/ # Optimization
│ │ ├── vrp_solver.py # OR-Tools CVRPTW model construction & extraction
│ │ ├── callbacks.py # Distance / demand / transit-duration callbacks
│ │ └── heuristics.py # Search-parameter builder (GLS)
│ ├── dynamic/ # Real-time layer
│ │ ├── reroute_engine.py # Ad-hoc insertion, cancellation, leg re-timing
│ │ └── eta_tracker.py # Driver GPS pings vs. planned schedule
│ ├── matrix/ # Travel-time matrices
│ │ ├── osrm_client.py # OSRM table client + synthetic fallback
│ │ └── distance_cache.py # Redis matrix cache, memory fallback
│ ├── repository/ # Persistence
│ │ ├── db.py # asyncpg pool (lazy, non-fatal)
│ │ ├── route_repo.py # Atomic route persistence + memory mirror
│ │ └── spatial_repo.py # Waypoint spatial clustering (Haversine)
│ └── transport/ # Edges (framework-facing)
│ ├── api/routes.py # /optimize, /{id}/reroute, /{id} GeoJSON
│ ├── api/orders.py # Order CRUD
│ └── events/event_listener.py # Redis pub/sub dispatcher, memory fallback
│
├── simulator/fleet_simulator.py # 15-vehicle / 120-stop city fleet benchmark
├── migrations/001_init_logistics_schema.sql # PostGIS schema bootstrap
├── osrm-data/ # Drop map.osrm extract here (optional)
├── tests/ # 6 pytest modules
├── logistics_vrp_optimizer_autonomous_blueprint.md # Design spec / ADR
├── docker-compose.yml # db · redis · osrm · optimizer_service
├── Dockerfile # python:3.11-slim + uvicorn
├── Makefile # up · down · test · run-service · run-simulator
└── pyproject.toml # Dependencies + pytest config
🚀 Quick Start
1. Clone
git clone https://github.com/Kavryx-Dev/dynamic-multi-stop-vehicle-routing-optimizer.git
cd dynamic-multi-stop-vehicle-routing-optimizer
2a. Full stack with Docker (recommended)
Brings up PostGIS + Redis + OSRM + the optimizer service.
# 2a: defaults work as-is — docker-compose injects its own env
docker compose up -d
docker compose ps
Check it is alive:
curl http://localhost:8000/health
# {"status":"healthy","service":"dynamic-vrp-optimizer","database_connected":true}
OSRM map data is optional. Without an extract in
osrm-data/, the OSRM container idles in standby and the client transparently uses the synthetic road matrix — the service stays fully functional.
2b. Local run without Docker
python -m venv .venv
# Windows
.venv\Scripts\activate
# macOS / Linux
source .venv/bin/activate
pip install -e ".[dev]"
Start Postgres and Redis yourself (or skip them — both are optional), then:
uvicorn cmd.optimizer_service.main:app --reload --port 8000
3. Configuration
Every value has a working default; nothing is required.
| Variable | Default | Used by |
|---|---|---|
DATABASE_URL | postgresql://postgres:postgres@localhost:5432/vrp_logistics | internal/repository/db.py:10 |
REDIS_URL | redis://localhost:6379/0 | internal/matrix/distance_cache.py:12 |
OSRM_URL | http://localhost:5000 | internal/matrix/osrm_client.py:13 |
POSTGRES_USER / POSTGRES_PASSWORD / POSTGRES_DB | postgres / postgres / vrp_logistics | docker-compose.yml:7 |
4. Run the benchmark
make run-simulator # or: python simulator/fleet_simulator.py
--- SIMULATION BENCHMARK REPORT ---
Vehicles: 15
Stops: 120
Rush Orders: 10
NN Baseline Duration: ...
CVRPTW Duration: ...
Savings: ...%
Time Window Violations: 0
Capacity Violations: 0
Latest measured run (8 vehicles / 40 stops, scaled for CI)
Transit time savings vs nearest-neighbour: 48.70%
Time window violations: 0
Capacity violations: 0
🔌 API Reference
| Method | Endpoint | Description |
|---|---|---|
GET | /health | Liveness plus real database connectivity |
POST | /api/v1/orders/ | Create an order (becomes a Waypoint) |
GET | /api/v1/orders/ | List orders |
GET | /api/v1/orders/{order_id} | Fetch one order |
POST | /api/v1/routes/optimize | Batch-plan the fleet. Body: { "problem": { depot, vehicles[], waypoints[] } } |
POST | /api/v1/routes/{route_id}/reroute | action: "insert" | "cancel" against an active plan |
GET | /api/v1/routes/{route_id} | GeoJSON FeatureCollection — LineString per vehicle, Point per stop |
Interactive docs are served at /docs while the app is running.
Example — plan a fleet
curl -X POST http://localhost:8000/api/v1/routes/optimize \
-H "Content-Type: application/json" \
-d '{
"problem": {
"depot": {
"id": "depot-central", "code": "DEPOT-BLR",
"lat": 12.9716, "lon": 77.5946,
"operating_window": { "start": 0, "end": 43200 }
},
"vehicles": [
{ "id": "van-01", "vehicle_code": "VAN-01", "depot_id": "depot-central",
"max_payload_kg": 800.0, "max_shift_seconds": 28800, "start_time": 0 }
],
"waypoints": [
{ "id": "stop-001", "order_number": "ORD-001",
"lat": 12.9784, "lon": 77.6408, "demand_kg": 42.5,
"time_window": { "start": 1800, "end": 14400 },
"service_duration_seconds": 300 }
],
"time_limit_seconds": 5,
"traffic_multiplier": 1.2
}
}'
Example — inject an urgent order in transit
curl -X POST http://localhost:8000/api/v1/routes/$PLAN_ID/reroute \
-H "Content-Type: application/json" \
-d '{
"action": "insert",
"urgent_waypoint": {
"id": "rush-01", "order_number": "RUSH-01",
"lat": 12.9352, "lon": 77.6245, "demand_kg": 30.0,
"time_window": { "start": 3600, "end": 27000 },
"service_duration_seconds": 300
},
"committed_stop_ids": ["stop-001"],
"max_solver_seconds": 2.5
}'
🧪 Testing
make test # or: pytest -v
pytest tests/test_time_window_invariants.py -v
| Module | Covers |
|---|---|
test_cvrptw_solver.py | Model construction, capacity, extraction |
test_time_window_invariants.py | Hard-window satisfaction and wait-time accounting |
test_dynamic_reroute.py | Insertion, cancellation, pinning, merged-plan validation |
test_osrm_fallback.py | Cache-miss, OSRM-down, synthetic asymmetric matrix |
test_api_endpoints.py | FastAPI contract and error paths |
test_fleet_simulator.py | End-to-end city benchmark vs. nearest-neighbour |
Current status: 16 passing, 1 failing.
test_fleet_simulator.py::test_city_fleet_simulation_and_benchmarkassertsurgent_stops_inserted > 0; on the seeded 8-vehicle/40-stop fixture the engine correctly refuses every insertion because the merged plan fails the hard time-window check (stop-040: arrival 15186s > latest 14400s). The three invariants it also asserts — zero time-window violations, zero capacity violations and ≥ 18 % savings (48.7 % measured) — all pass. This is a real open item, not a flaky test, and it is tracked honestly rather than asserted as green.
🧠 Design Decisions
| Decision | Why |
|---|---|
| Drop disjunctions, not hard failure | A 1,000,000 penalty lets the solver shed an impossible stop and report it, instead of returning nothing useful to a dispatcher. |
| Validator as a separate gate | Keeping ConstraintValidator out of the solver means the merged dynamic plan is checked by the same code that checks the batch plan — one definition of correctness. |
| Synthetic matrix with direction asymmetry | A symmetric Haversine fallback would make cost(i,j) == cost(j,i), which silently rewards out-and-back routes. The fallback models one-way streets and traffic. |
| Circuit-breaker on OSRM | After one failure the client stops issuing requests for the process lifetime, so a dead OSRM costs one timeout instead of one per solve. |
| Memory mirrors for every store | Repo, orders, active plans and the matrix cache all keep an in-process copy, so the service degrades rather than 500s. |
| Demand × 100 scaling | OR-Tools capacity dimensions are integral; scaling avoids rounding a 42.5 kg stop into an off-by-a-lot load. |
Go-style internal/ layout | cmd/ holds entry points, internal/ holds everything replaceable. Domain code imports no framework. |
🗺️ Roadmap
- Choose and add a
LICENSEfile - Fix the seeded benchmark fixture so urgent insertions are feasible at CI scale
- Multi-depot support (currently one depot per problem)
- Volume (
m3) enforced as a second capacity dimension - Persist active plans so the service can run more than one replica
- Driver app contract for
ETATrackerpings (currently a standby loop) - Wire up — or remove — dormant code:
SpatialRepository.cluster_waypoints_by_distance,Waypoint.is_pinnedand the solver'spinned_routesargument are implemented but not yet on the normal call path
🤝 Contributing
Contributions are welcome. Please:
- Keep
internal/domain/free of I/O and framework imports — it is the testable core. - Add a test for new behaviour;
tests/is the contract. - Never relax a
ConstraintValidatorinvariant to make a test pass. Fix the planner. - Run
pytest -vbefore opening a PR, and be explicit in the PR description about any known failure.
📄 License
No LICENSE file has been added yet — see the roadmap. Until one is chosen, all rights are reserved by default. If MIT is intended, add a LICENSE file and flip the badge above.
Diagrams are native Mermaid and render directly on GitHub.