Comparing OR-Tools and Greedy Heuristics for Inspection Routing
This guide sits under Optimizing Inspector Route Scheduling with Python, part of the Inspection Scheduling & Field Operations track, and answers the question that decides your whole routing stack: for your fleet size, appointment constraints, and server, should the daily board come from an OR-Tools solver, a greedy nearest-neighbor pass, or a Clarke-Wright savings heuristic?
The choice is not academic. A building department running twelve inspectors against three hundred daily stops on a shared, constrained municipal VM has very different needs from a small township dispatching four inspectors from a laptop. Pick a heavy constraint solver where a heuristic would do and you carry a 60 MB dependency and multi-second solve times you did not need. Pick a greedy pass where windows are tight and you break appointment promises the solver would have kept. This guide compares three approaches on the four axes that actually govern the decision — solution quality, runtime, dependency weight on constrained hardware, and which constraints each can honor — then gives you a decision rule and the failure modes of each.
Permalink to this section The Decision in One Table
The same comparison in plain text, which is what you paste into an architecture decision record:
| Axis | OR-Tools routing | Nearest-neighbor | Clarke-Wright savings |
|---|---|---|---|
| Solution quality | Near-optimal (guided local search) | Rough, 10–25% over best-known | Good, 5–12% over best-known |
| Runtime (200 stops) | 2–10 s, time-budgeted | < 50 ms | ~200–600 ms |
| Dependency weight | Heavy (~60 MB native wheel) | None (standard library) | Light (NumPy) |
| Time windows | First-class dimension | Not honored | Only via post-hoc repair |
| Workload balance | Global span cost | None | Route-count cap only |
| Skill/discipline rules | Hard AllowedVehicles |
Manual filter | Manual filter |
| Best fit | Tight windows, large fleet | Prototype, tiny fleet, fallback | Mid-size, loose windows, thin server |
Permalink to this section When Each One Wins
Reach for OR-Tools when appointment windows are tight, the fleet is large enough that assignment (not just ordering) matters, or discipline and workload-balance constraints are hard requirements. Its routing library models time windows as a native dimension and balances load through a span-cost coefficient, which no heuristic does natively. The cost is a ~60 MB prebuilt wheel and multi-second solves — acceptable for a once-a-morning dispatch run, awkward for a function that must answer in 50 ms.
Reach for nearest-neighbor when you are prototyping, the fleet is tiny, windows are loose, or you need a dependency-free fallback that always produces a board when the solver times out. It is the honest baseline built in the parent guide, and its real production role is exactly that: the safety net behind the solver.
Reach for Clarke-Wright savings when you want most of the solver’s quality on a fraction of the dependency and runtime budget, and your windows are loose enough that a light post-hoc repair pass can fix the few violations. It is the sweet spot for a mid-size township on a shared VM that cannot spare 60 MB and several CPU-seconds per run.
Permalink to this section Step 1 — Score a Route So the Comparison Is Fair
Every method must be judged by the same yardstick: total travel minutes plus a penalty for any appointment window it violates. Without a shared scorer you cannot say one method “wins.”
from datetime import time
def route_cost(matrix: list[list[int]], route: list[int], stops: list,
window_penalty: int = 500) -> int:
"""Total travel minutes for one route, plus a stiff penalty per missed window."""
if not route:
return 0
clock = 0 # running minutes-since-midnight along the route
node = 0 # depot
total_travel = 0
for stop_idx in route:
travel = matrix[node][stop_idx + 1] # +1: node 0 is the depot
total_travel += travel
clock = max(clock + travel, _mins(stops[stop_idx].window_open))
if clock > _mins(stops[stop_idx].window_close):
total_travel += window_penalty # late arrival: broken promise
clock += stops[stop_idx].service_minutes
node = stop_idx + 1
return total_travel
def _mins(t: time) -> int:
return t.hour * 60 + t.minute
Run this over every method’s output on the same day of stops and you have a single, comparable number — travel plus broken-window penalties — that tells you what each approach actually costs your residents and your fleet.
Permalink to this section Step 2 — The Clarke-Wright Savings Middle Ground
Clarke-Wright is the method most municipal teams have never tried and most should. It starts with every stop on its own out-and-back route, then greedily merges the two routes whose merge “saves” the most travel — the savings of joining stops i and j is d(depot,i) + d(depot,j) - d(i,j). It needs only NumPy and consistently lands within about ten percent of the solver on capacity-constrained problems.
import numpy as np
def clarke_wright(matrix: list[list[int]], demand_count: int,
vehicle_capacity: int) -> list[list[int]]:
"""Savings-based routes. Node 0 is the depot; nodes 1..n map to stops 0..n-1."""
d = np.array(matrix)
n = demand_count
# Savings s(i,j) = d(0,i) + d(0,j) - d(i,j); higher means a better merge.
savings = []
for i in range(1, n + 1):
for j in range(i + 1, n + 1):
savings.append((d[0, i] + d[0, j] - d[i, j], i, j))
savings.sort(reverse=True) # merge best savings first
routes: dict[int, list[int]] = {i: [i] for i in range(1, n + 1)}
route_of = {i: i for i in range(1, n + 1)} # node -> route key
for _, i, j in savings:
ri, rj = route_of[i], route_of[j]
if ri == rj:
continue # already in the same route
left, right = routes[ri], routes[rj]
# Only merge at endpoints, and only if capacity allows.
if left[-1] == i and right[0] == j and len(left) + len(right) <= vehicle_capacity:
merged = left + right
routes[ri] = merged
for node in right:
route_of[node] = ri
del routes[rj]
# Return as 0-based stop indices to match the rest of the pipeline.
return [[node - 1 for node in r] for r in routes.values()]
Clarke-Wright respects capacity natively but not time windows — that is its one real weakness. If your windows are loose (half-day rather than one-hour), a single repair pass that reorders each route by window-open time recovers most feasibility. If your windows are tight, this is the signal to move up to OR-Tools rather than pile repair heuristics on top.
Permalink to this section Step 3 — Wrap the Choice Behind One Interface
Do not scatter if use_ortools across the codebase. Expose one plan_routes function that dispatches on a configured strategy, so switching methods — or falling back when the solver times out — is a one-line config change and a testable seam.
from typing import Callable, Literal
Strategy = Literal["ortools", "greedy", "savings"]
def plan_routes(strategy: Strategy, matrix, stops, inspectors, **kw) -> dict[str, list[int]]:
match strategy:
case "ortools":
result = solve_routes(matrix, stops, inspectors,
time_budget_seconds=kw.get("time_budget", 10))
if result is None: # solver gave up under budget
return nearest_neighbor_routes(matrix, stops, inspectors) # safe fallback
return result
case "savings":
cap = max(i.max_stops for i in inspectors)
raw = clarke_wright(matrix, len(stops), cap)
return {insp.name: route for insp, route in zip(inspectors, raw)}
case "greedy":
return nearest_neighbor_routes(matrix, stops, inspectors)
The ortools branch degrading to the greedy baseline is the most important line here: dispatch must never return an empty board because the solver ran out of time on an unusually hard day. The routing internals behind solve_routes and nearest_neighbor_routes are built in the parent guide on optimizing inspector route scheduling; this page only chooses between them.
Permalink to this section Parameter and Flag Reference
| Setting | Applies to | Recommended | Rationale for permit inspections |
|---|---|---|---|
time_budget_seconds |
OR-Tools | 5–15 | Fits a morning dispatch run; beyond ~15 s quality gains flatten for typical day sizes. |
window_penalty |
scorer | 500 | Must dwarf a normal inter-stop travel time so a missed window always outweighs a detour. |
vehicle_capacity |
savings / greedy | shift ÷ avg visit | Cap route length by real shift math, not a round number. |
fallback_strategy |
dispatcher | greedy |
The zero-dependency method that always returns a board when the solver times out. |
repair_pass |
savings | sort_by_window |
Cheap post-merge reorder that recovers loose-window feasibility. |
savings_capacity_slack |
savings | 0 | Increase only if you accept slightly over-long routes to reduce vehicle count. |
Permalink to this section Common Failure Patterns and Fixes
Permalink to this section Comparing methods on different days
Running OR-Tools on Monday’s stops and greedy on Tuesday’s and concluding one is better is the most common mistake. Freeze one representative day (or several) as a fixture and score every method against the identical input with route_cost. Only then are the numbers comparable.
Permalink to this section Greedy declared “good enough” because windows were ignored
Nearest-neighbor looks competitive on total travel precisely because it never checks windows — it is cheap because it skips the hard part. Always score with the window_penalty term included; a greedy route that arrives late to a third of its stops is not cheaper, it is broken.
Permalink to this section Clarke-Wright merges into oversized routes
Without the capacity guard in the merge condition, savings will happily fuse two half-day routes into one twelve-hour marathon. Enforce len(left) + len(right) <= vehicle_capacity at every merge, and treat any route exceeding shift length as a hard reject, not a warning.
Permalink to this section OR-Tools dependency breaks a constrained deploy
The prebuilt wheel pulls native libraries that occasionally clash on locked-down or older municipal images, and 60 MB matters on a thin VM. If the wheel won’t install cleanly, default to Clarke-Wright — it delivers most of the quality on a NumPy-only footprint — rather than forcing the heavy dependency onto hardware that can’t take it.
Permalink to this section Solver timeout silently returns an empty board
If SolveWithParameters returns None under a tight budget and nothing catches it, dispatch gets no routes at all. Always wire the fallback in plan_routes so a timeout degrades to the greedy baseline, and log the timeout with the day’s stop count so you can raise the budget for that size.
Permalink to this section Audit and Logging Guidance
Route planning is a decision a resident may later question — “why didn’t the inspector make it in my window?” — so log enough to reconstruct any day’s board. For each dispatch run record the strategy used, the input day (stop IDs, windows, fleet), the per-route travel and the route_cost score, any stops the solver dropped, and whether a fallback fired. Keep these for the state-mandated records-retention period, using the same append-only, hash-chained pattern applied when securing municipal API endpoints for third-party integrations, so a compliance officer can prove the routing was deterministic and unaltered. Log strategy changes with particular care: switching from OR-Tools to a heuristic shifts which appointment promises get kept, and that policy shift should be attributable to a person and a date, not buried in a config diff. When the demand feeding a run arrives in bulk, route it through the shared error handling and retry logic for ingestion pipelines so a malformed stop is quarantined and traceable rather than silently skewing the board.
Permalink to this section Frequently Asked Questions
Permalink to this section If OR-Tools gives the best routes, why consider anything else?
Because “best routes” is only one axis. OR-Tools carries a heavy native dependency and multi-second solves that a constrained municipal VM or a low-latency endpoint may not tolerate. When windows are loose, Clarke-Wright reaches within about ten percent of optimal on a NumPy-only footprint, and nearest-neighbor is the dependency-free fallback you need anyway. Match the method to the fleet size, the window tightness, and the server — not to a benchmark.
Permalink to this section How do I know if my appointment windows are “tight” enough to need OR-Tools?
A practical test: score a Clarke-Wright plan with a window-open sort repair pass, then count the stops whose arrival still misses their window. If that count is near zero on representative days, your windows are loose and the savings heuristic is fine. If a meaningful fraction miss, the windows genuinely constrain the assignment and only a solver that treats them as a first-class dimension will keep those promises.
Permalink to this section Can I mix methods across the fleet?
Yes, and it is often the pragmatic answer. Run OR-Tools for the dense, tight-window downtown district where assignment quality pays off, and Clarke-Wright or greedy for a rural fringe where stops are far apart and windows are wide. The single plan_routes interface makes per-district strategy a config value, and the shared scorer keeps the comparison honest across the whole board.
Permalink to this section Related
- Optimizing Inspector Route Scheduling with Python — the parent guide that builds the OR-Tools solver and greedy baseline this page compares.
- Routing Inspectors by Geographic Proximity with Python — the travel-cost models every method here depends on.
- Syncing Inspector Calendars with Permit Milestones — where the daily demand these methods route actually comes from.
- Inspection Scheduling & Field Operations — the parent track for scheduling, routing, and field capture.