Kavryx Dev
← All products
GeospatialPROTOTYPE

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 solverOR-Tools RoutingModel with PATH_CHEAPEST_ARC first solution and guided local search, under a configurable time limit.
⏱️ Hard time windowsEvery 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 limitsPayload (kg) enforced as an OR-Tools capacity dimension, plus a per-vehicle max_shift_seconds bound on End − Start.
🚨 In-transit reroutingAd-hoc urgent orders and cancellations re-optimize only the uncommitted tail, under a 2.5 s budget.
📌 Pinned stopsVisited and in-transit stops are immutable — a reroute can never move a truck that already made its stop.
🛡️ Invariant gatePayload, hard time window, shift duration and depot start/end are re-checked on every plan (internal/domain/constraints.py).
🌐 Real road matricesOSRM /table/v1/driving with a SHA-256-keyed Redis cache (24 h TTL) layered over real maps.
🧱 Degraded modeOSRM timeout → synthetic asymmetric road matrix. Redis/Postgres down → in-memory stores.
🗺️ GeoJSON outputGET /api/v1/routes/{id} returns a FeatureCollection with a LineString per vehicle and a Point per stop.
📊 Benchmark harness15-vehicle / 120-stop city fleet with a nearest-neighbour baseline and rush-order injection.

🏗️ System Architecture

Rendering diagram…

Component breakdown

ComponentResponsibilitySource
Optimizer ServiceFastAPI 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 SolverBuilds 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 Engineinsert_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 ClientCache-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 ValidatorIndependent re-check of the four invariants; returns (is_valid, violations). Raises ConstraintViolationError-shaped failures upstream.internal/domain/constraints.py:19
Route RepositoryPersists 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 CacheRedis 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 Engineosrm/osrm-backend with the MLD algorithm; enters standby if no .osrm extract is present in ./osrm-data.docker-compose.yml:35
PostgreSQL + PostGISdepots, 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 ConstraintValidator is 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

Rendering diagram…

Step by step

  1. Intake — POST /api/v1/routes/optimize accepts a validated OptimizationProblem: one Depot, N VehicleConstraints, M Waypoints. Pydantic rejects an inverted time window at the edge (start > end).
  2. 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.
  3. Model construction — node 0 is the depot; every vehicle starts and ends there. Demands scale ×100 for integer precision. The Time dimension gets slack_max = 86400 (waiting is allowed) but each stop's cumul variable is range-set to its window (late is not).
  4. Search — PATH_CHEAPEST_ARC seeds the first solution, guided local search improves it within time_limit_seconds (default 5 s).
  5. Disjunction handling — every waypoint carries a drop disjunction with a 1,000,000 penalty, so a stop that cannot fit is returned in unassigned_waypoints and the plan status becomes UNASSIGNABLE_DUE_TO_CONSTRAINTS rather than failing outright.
  6. The invariant gate — the assembled plan is re-checked for payload, hard time window, shift duration and depot start/end. A violation raises ValueError and no plan is returned.
  7. Persistence — save_plan writes each vehicle route in a transaction while retaining an in-memory copy, so GET /api/v1/routes/{id} keeps working if Postgres is down.
  8. Fan-out — a ROUTE_UPDATED event 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

Rendering diagram…

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

LayerTechnologyVersionRole
LanguagePython>=3.11, <3.12Runtime (pinned for OR-Tools wheels)
APIFastAPI>=0.110REST layer, Pydantic v2 validation, lifespan
ASGIUvicorn>=0.28Server
SolverGoogle OR-Tools>=9.9CVRPTW routing model + guided local search
ValidationPydantic>=2.6Domain model invariants at the boundary
DatabasePostgreSQL + PostGIS16 / 3.4asyncpg pool, geospatial order geometry
Cache / BusRedis7Matrix cache (24 h TTL) + pub/sub events
RoutingOSRM BackendlatestReal road distance & duration matrices (MLD)
HTTP clientrequests, httpx>=2.31, >=0.27OSRM table calls
Testingpytest, pytest-asyncio>=8.0, >=0.236 test modules, asyncio_mode = auto
PackagingHatchling—pyproject.toml build backend
DevOpsDocker / 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.

VariableDefaultUsed by
DATABASE_URLpostgresql://postgres:postgres@localhost:5432/vrp_logisticsinternal/repository/db.py:10
REDIS_URLredis://localhost:6379/0internal/matrix/distance_cache.py:12
OSRM_URLhttp://localhost:5000internal/matrix/osrm_client.py:13
POSTGRES_USER / POSTGRES_PASSWORD / POSTGRES_DBpostgres / postgres / vrp_logisticsdocker-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

MethodEndpointDescription
GET/healthLiveness 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/optimizeBatch-plan the fleet. Body: { "problem": { depot, vehicles[], waypoints[] } }
POST/api/v1/routes/{route_id}/rerouteaction: "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
ModuleCovers
test_cvrptw_solver.pyModel construction, capacity, extraction
test_time_window_invariants.pyHard-window satisfaction and wait-time accounting
test_dynamic_reroute.pyInsertion, cancellation, pinning, merged-plan validation
test_osrm_fallback.pyCache-miss, OSRM-down, synthetic asymmetric matrix
test_api_endpoints.pyFastAPI contract and error paths
test_fleet_simulator.pyEnd-to-end city benchmark vs. nearest-neighbour

Current status: 16 passing, 1 failing. test_fleet_simulator.py::test_city_fleet_simulation_and_benchmark asserts urgent_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

DecisionWhy
Drop disjunctions, not hard failureA 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 gateKeeping 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 asymmetryA 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 OSRMAfter 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 storeRepo, orders, active plans and the matrix cache all keep an in-process copy, so the service degrades rather than 500s.
Demand × 100 scalingOR-Tools capacity dimensions are integral; scaling avoids rounding a 42.5 kg stop into an off-by-a-lot load.
Go-style internal/ layoutcmd/ holds entry points, internal/ holds everything replaceable. Domain code imports no framework.

🗺️ Roadmap

  • Choose and add a LICENSE file
  • 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 ETATracker pings (currently a standby loop)
  • Wire up — or remove — dormant code: SpatialRepository.cluster_waypoints_by_distance, Waypoint.is_pinned and the solver's pinned_routes argument are implemented but not yet on the normal call path

🤝 Contributing

Contributions are welcome. Please:

  1. Keep internal/domain/ free of I/O and framework imports — it is the testable core.
  2. Add a test for new behaviour; tests/ is the contract.
  3. Never relax a ConstraintValidator invariant to make a test pass. Fix the planner.
  4. Run pytest -v before 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.