A fleet where every vehicle is identical and everything starts from one depot is the textbook problem and almost never the real one. Real fleets have vans and artics with different capacities, two or three depots, and stops that would be nice to serve but are not obligatory. All three are expressible in OR-Tools without leaving the standard model, and this page covers how — as part of vehicle routing problem solvers and fleet dispatch within Routing API Automation & Fleet Integration.
The recurring surprise is that these constraints interact. Adding a depot can make a time-windowed problem easier; adding a vehicle can make it infeasible. Neither is a bug, and both are easier to reason about once the mechanism is visible.
When to use this approach
Model capacity explicitly when the fleet is heterogeneous or the goods are dense enough to bind. Model multiple depots when vehicles genuinely start from different places — not when they merely pass through several. Use disjunctions when the business would rather serve most stops well than all stops badly, which is most parcel and grocery work and almost no pharmaceutical or industrial work.
Skip capacity entirely when it never binds. A dimension the solver never has to respect still costs search time, and a fleet delivering documents has no meaningful weight constraint.
Implementation
Multiple depots are expressed at construction time, through the index manager rather than through any depot-specific API.
# requires: ortools (pip install ortools)
from ortools.constraint_solver import pywrapcp
def build_multi_depot(matrix, starts: list[int], ends: list[int]):
"""One start and one end index per vehicle — they need not be equal."""
assert len(starts) == len(ends), "one start and one end per vehicle"
manager = pywrapcp.RoutingIndexManager(len(matrix), len(starts), starts, ends)
routing = pywrapcp.RoutingModel(manager)
def transit(from_index: int, to_index: int) -> int:
i = manager.IndexToNode(from_index)
j = manager.IndexToNode(to_index)
return int(matrix[i][j])
routing.SetArcCostEvaluatorOfAllVehicles(
routing.RegisterTransitCallback(transit)
)
return manager, routing
Because starts and ends are independent lists, this same call covers one-way and relay operations — a vehicle that begins at a depot and ends at a driver’s home base is just an entry where starts[v] != ends[v].
Capacity is a dimension whose increment is the demand at the node being left:
# requires: ortools (pip install ortools)
def add_capacity_dimension(routing, manager, demands: list[int],
capacities: list[int], name: str = "Capacity") -> None:
"""Per-vehicle capacity; no slack, because load cannot be waited out."""
def demand(from_index: int) -> int:
return int(demands[manager.IndexToNode(from_index)])
idx = routing.RegisterUnaryTransitCallback(demand)
routing.AddDimensionWithVehicleCapacity(
idx,
0, # slack
capacities, # one entry per vehicle
True, # start cumulative at zero
name,
)
Calling this twice with different demand lists and capacities gives independent weight and volume constraints over the same routes, which is the right model whenever the fleet carries mixed goods. The two rarely bind at the same time, and which one binds flips with the manifest.
Optional stops are priced rather than permitted:
# requires: ortools (pip install ortools)
def make_stops_optional(routing, manager, optional_nodes: list[int],
penalty: int) -> None:
"""A disjunction lets the solver drop a stop at a stated cost."""
for node in optional_nodes:
routing.AddDisjunction([manager.NodeToIndex(node)], penalty)
The penalty is what makes this useful and what makes it dangerous. Set it below the cost of serving the stop and the solver drops it every time; set it far above and the disjunction has no effect and you have paid search time for nothing. Several times the longest arc in the matrix is a defensible starting point, tuned against what a missed delivery actually costs.
Key parameters and tuning
| Parameter | Recommended value | Notes |
|---|---|---|
| Capacity slack | 0 | Load cannot be shed by waiting; slack here hides over-capacity plans |
| Capacity dimensions | 1–2 | Weight and volume; a third is rarely binding and costs search time |
| Disjunction penalty | 3–10× the longest arc | Below the serving cost, everything is dropped; far above, it does nothing |
| Starts and ends | explicit lists | Equal values give the classic single-depot model as a special case |
| Empty-route handling | allow | Forcing every vehicle to be used produces worse plans on light days |
| Vehicle count | slightly above need | Too few is infeasible; far too many slows search without improving quality |
| Fixed vehicle cost | set where fleets are hired | Makes the solver prefer fewer vehicles when marginal use is expensive |
Integration points
With the time dimension. Capacity and time interact through feasibility only, but the interaction is strong. Splitting a route to respect capacity adds a depot return, which consumes time and can push later stops outside their windows. If a model becomes infeasible after capacities are added, the binding constraint is usually time — see modelling time windows in OR-Tools VRP.
With the matrix. A multi-depot model needs every depot in the matrix, and depot-to-depot rows matter for relay operations. Omitting them is easy to do when the matrix is built from the stop manifest alone, and produces unreachable-pair costs on exactly the arcs a relay plan needs — see feeding a travel-time matrix into OR-Tools.
With EV constraints. Capacity and battery range are different dimensions, and modelling energy as a capacity dimension does not work because energy is recovered on descents. Solve the routing problem with weight and volume, then repair energy separately with inserting charging stops in route optimization.
Validation checklist
- Both capacity dimensions are re-derived from the plan. Sum demands along each route independently of the solver and compare against the vehicle’s limits. Trusting the solver’s own cumulative values hides mis-specified callbacks.
- Every vehicle’s start and end appear in the matrix. A depot missing from the matrix shows up as an unreachable-pair cost on the arcs a relay plan needs, not as an error.
- An empty route is legal. Confirm the model solves on a light day where one vehicle is not needed. If it does not, a depot window or a fixed cost is forcing use.
- Disjunction penalties bind in the right direction. Halve the penalty and confirm more stops drop; double it and confirm fewer do. If neither changes the plan, the penalty is far outside the range where it matters.
- Adding a depot does not silently reassign everything. Compare plans before and after. Large-scale reassignment is legitimate, but dispatchers need to know it happened rather than discover it in the field.
Related
- Vehicle routing problem solvers and fleet dispatch — the surrounding model, search parameters and independent solution audit
- Modelling time windows in OR-Tools VRP — the dimension capacity most often interacts with
- Feeding a travel-time matrix into OR-Tools — ensuring every depot has rows and columns in the matrix
- EV fleet charging-aware route optimization — why energy is a repair step rather than another capacity dimension