Optimizing Inspector Route Scheduling with Python

This guide is part of the Inspection Scheduling & Field Operations track, which turns issued permits into an efficient day of field work, and it tackles the hardest daily problem in that track: deciding which inspector drives to which site, in which order, so a fleet clears its backlog without burning the afternoon in traffic.

Permalink to this section Problem Statement and Scope

Every morning a building department has more inspection stops than daylight. A residential re-roof needs a final, a commercial tenant improvement needs a rough electrical, a pool barrier needs a safety check before a holiday weekend — and each of those appointments carries a promised arrival window the resident planned their day around. Left to a spreadsheet, dispatch degrades into the same three failures every office recognizes:

  • Windshield time eats the day. Stops are handed out by ZIP code or by whoever asked first, so an inspector crosses the jurisdiction twice and finishes four calls short. The cost is real: fewer inspections per shift means longer permit cycle times and a growing queue of contractors waiting on a green tag.
  • Broken appointment windows. A resident is told “between 1 and 3,” the route runs late, and the inspector arrives at 4:15 to a locked gate. The visit is wasted, gets rescheduled, and the department eats a second trip.
  • Lopsided workloads. One inspector draws a dense downtown cluster and finishes by 2 p.m.; another draws the rural fringe and works past 5. Over a week the imbalance shows up as overtime on one route and idle capacity on another.

The people who feel this are concrete: dispatchers and field supervisors who build the board by hand every morning, Python automation builders asked to make that board reproducible, and inspectors whose day is defined by how well the route was planned. What they are really solving is a classic operations-research problem in municipal clothing — a capacitated vehicle-routing problem with time windows (CVRPTW). Each inspector is a vehicle with a shift-length capacity; each stop is a node with a service duration and an arrival window; the objective is to minimize total travel while respecting those windows and spreading the load.

The inputs are a list of due inspections (each with a location, a service time, and an appointment window), a fleet of inspectors (each with a depot, a shift start/end, and a daily capacity), and a way to measure travel cost between any two points. The outputs are one ordered route per inspector — a sequence of stops with predicted arrival times — plus a feasibility report flagging any inspection that could not be placed inside its window. This guide builds that pipeline twice: a fast nearest-neighbor baseline you can ship in an afternoon, and a constraint-aware OR-Tools solver that handles the windows and balancing properly. Where they diverge, and which to run on a constrained municipal server, is the subject of the companion guide on comparing OR-Tools and greedy heuristics for inspection routing.

Daily inspector route optimization pipeline The day's inspection demand — each stop carrying a service time and an appointment window — flows left to right into a pairwise cost matrix of travel times, then into a route solver. The solver is fed from below by four constraints: appointment time windows, per-inspector shift capacity, workload balance, and skill or discipline matching. It emits one balanced, ordered route per inspector along with a report of stops that could not be placed inside their windows. A dashed alternate path sends the same cost matrix through a nearest-neighbor greedy baseline, used as a fast sanity check against the solver's result. stops travel costs ordered plan Inspection demand due stops · service time appointment windows Cost matrix pairwise travel time haversine or routing Route solver OR-Tools CVRPTW minimize travel Balanced routes one per inspector + unplaced report Constraints feed the solver time windows · shift capacity workload balance · skill match Nearest-neighbor baseline O(n²) greedy sanity check

Permalink to this section Prerequisites

This implementation targets Python 3.10+ (the examples use match/case, dataclass slots, and modern type unions). The heavy lifting is done by Google OR-Tools, which ships prebuilt wheels for the common Linux/macOS/Windows targets, so no C++ toolchain is required on the app host.

pip install "ortools>=9.9" "numpy>=1.26" "geopy>=2.4" "pydantic>=2.6" "structlog>=24.1"

Environment assumptions:

  • A source of due inspections for the day. In a real deployment these arrive from the scheduling layer that keeps inspector calendars synced with permit milestones, so the route optimizer consumes a clean list rather than scraping a calendar itself.
  • Geocoded stop locations. Each inspection must carry a latitude/longitude; if your records only hold a parcel ID, resolve coordinates upstream so this stage never blocks on a geocoder round-trip.
  • A travel-cost source. A straight-line (haversine) matrix is fine to start and needs no network. When accuracy matters, swap in a self-hosted routing engine — the interface is identical, as covered when routing inspectors by geographic proximity with Python.
  • Inspector definitions: a home depot (often the city yard or the inspector’s assigned district office), a shift window in local time, and a per-day stop capacity.

Permalink to this section Step 1 — Model the Day’s Demand and Fleet

Before any solver runs, pin down the two data structures everything else references: the stops and the inspectors. Keeping these as typed, immutable records makes the pipeline testable and stops a stray None window from silently becoming “any time.”

from __future__ import annotations

from dataclasses import dataclass, field
from datetime import time


@dataclass(frozen=True, slots=True)
class Stop:
    permit_id: str
    lat: float
    lon: float
    service_minutes: int              # how long the inspection itself takes on site
    window_open: time                 # earliest the resident agreed to be available
    window_close: time                # latest promised arrival
    discipline: str = "general"       # e.g. "electrical", "plumbing", "general"


@dataclass(frozen=True, slots=True)
class Inspector:
    name: str
    depot_lat: float
    depot_lon: float
    shift_start: time
    shift_end: time
    max_stops: int                    # capacity: bounds a single route's length
    disciplines: frozenset[str] = field(default_factory=lambda: frozenset({"general"}))


def minutes_since_midnight(t: time) -> int:
    """Solvers work in a flat integer time axis; convert clock times once, up front."""
    return t.hour * 60 + t.minute

The discipline field matters more than it looks: many jurisdictions require a licensed electrical inspector for an electrical rough, and handing that stop to a general inspector is not a routing inefficiency — it is an invalid inspection. We enforce it as a hard constraint later. Freezing the dataclasses means a stop’s window cannot be mutated mid-solve, which is the kind of bug that produces a route that looked feasible in the log but wasn’t on the road.

Permalink to this section Step 2 — Build the Travel-Cost Matrix

Every routing algorithm needs the cost of going from any point to any other point. Model the depot as node 0 and each stop as a subsequent node, then compute a symmetric matrix of travel minutes. Start with haversine distance divided by an assumed average speed — it is deterministic, needs no network, and is good enough to prove the pipeline before you invest in a routing engine.

from math import asin, cos, radians, sin, sqrt


def haversine_km(a_lat: float, a_lon: float, b_lat: float, b_lon: float) -> float:
    r = 6371.0088  # mean Earth radius, km
    dlat, dlon = radians(b_lat - a_lat), radians(b_lon - a_lon)
    h = sin(dlat / 2) ** 2 + cos(radians(a_lat)) * cos(radians(b_lat)) * sin(dlon / 2) ** 2
    return 2 * r * asin(sqrt(h))


def build_time_matrix(
    depot: tuple[float, float],
    stops: list[Stop],
    avg_speed_kmh: float = 38.0,      # conservative urban+arterial blend
) -> list[list[int]]:
    """Return an (n+1)x(n+1) matrix of integer travel MINUTES; node 0 is the depot."""
    points = [depot] + [(s.lat, s.lon) for s in stops]
    n = len(points)
    matrix = [[0] * n for _ in range(n)]
    for i in range(n):
        for j in range(n):
            if i == j:
                continue
            km = haversine_km(*points[i], *points[j])
            # Round UP: underestimating travel is what breaks appointment windows.
            matrix[i][j] = int(km / avg_speed_kmh * 60 + 0.9999)
    return matrix

Two municipal-specific choices are baked in here. The average speed is deliberately conservative because a route planned at optimistic highway speeds arrives late to every window after the first. And travel minutes round up — an underestimate is not a rounding error, it is a broken promise to a resident. When you later replace haversine with a real road-network matrix, keep this integer-minutes contract so the solver code below never changes.

Permalink to this section Step 3 — Ship a Nearest-Neighbor Baseline First

Before reaching for a constraint solver, build the simplest thing that could possibly work: a greedy nearest-neighbor tour per inspector. From the depot, repeatedly hop to the closest unvisited stop the current inspector can serve, respecting only capacity and discipline. It ignores time windows and makes no attempt at global optimality, but it runs in milliseconds, has zero heavy dependencies, and gives you a number to beat.

def nearest_neighbor_routes(
    matrix: list[list[int]],
    stops: list[Stop],
    inspectors: list[Inspector],
) -> dict[str, list[int]]:
    """Greedy per-inspector tours. Returns inspector name -> list of stop indices (0-based into `stops`)."""
    unvisited: set[int] = set(range(len(stops)))
    routes: dict[str, list[int]] = {}
    for inspector in inspectors:
        current_node = 0                 # start at the depot (matrix node 0)
        route: list[int] = []
        while len(route) < inspector.max_stops and unvisited:
            servable = [
                i for i in unvisited
                if stops[i].discipline in inspector.disciplines
                or stops[i].discipline == "general"
            ]
            if not servable:
                break
            # matrix is offset by one because node 0 is the depot.
            nxt = min(servable, key=lambda i: matrix[current_node][i + 1])
            route.append(nxt)
            unvisited.discard(nxt)
            current_node = nxt + 1
        routes[inspector.name] = route
    return routes

This is the honest baseline. If the OR-Tools solver in the next step cannot beat this greedy tour on total travel minutes, something is misconfigured — and that is exactly the sanity check to keep in your test suite. The head-to-head on solution quality, runtime, and dependency weight is laid out in full in the comparison of OR-Tools and greedy heuristics.

Permalink to this section Step 4 — Solve the Constrained Problem with OR-Tools

Now model the real problem. OR-Tools’ routing library represents each inspector as a vehicle, each stop as a node, and lets you attach dimensions — cumulative quantities like elapsed time — that carry the time-window and capacity constraints. The solver searches for an assignment of stops to vehicles and an order within each route that minimizes total travel while keeping every dimension inside its bounds.

from ortools.constraint_solver import pywrapcp, routing_enums_pb2


def solve_routes(
    matrix: list[list[int]],
    stops: list[Stop],
    inspectors: list[Inspector],
    time_budget_seconds: int = 10,
) -> dict[str, list[int]] | None:
    """Return inspector name -> ordered stop indices, or None if no feasible plan exists."""
    n_nodes = len(matrix)                # depot + stops
    n_vehicles = len(inspectors)
    depot = 0
    manager = pywrapcp.RoutingIndexManager(n_nodes, n_vehicles, depot)
    routing = pywrapcp.RoutingModel(manager)

    # --- travel + service time callback ---
    def transit(from_index: int, to_index: int) -> int:
        i, j = manager.IndexToNode(from_index), manager.IndexToNode(to_index)
        service = stops[j - 1].service_minutes if j != depot else 0
        return matrix[i][j] + service
    transit_cb = routing.RegisterTransitCallback(transit)
    routing.SetArcCostEvaluatorOfAllVehicles(transit_cb)

    # --- Time dimension carries the appointment windows ---
    horizon = 24 * 60
    routing.AddDimension(transit_cb, slack_max=60, capacity=horizon,
                         fix_start_cumul_to_zero=False, name="Time")
    time_dim = routing.GetDimensionOrDie("Time")
    for node in range(1, n_nodes):
        s = stops[node - 1]
        idx = manager.NodeToIndex(node)
        time_dim.CumulVar(idx).SetRange(
            minutes_since_midnight(s.window_open),
            minutes_since_midnight(s.window_close),
        )
    # Each vehicle's route must start/end within the inspector's shift.
    for v, inspector in enumerate(inspectors):
        start = time_dim.CumulVar(routing.Start(v))
        start.SetRange(minutes_since_midnight(inspector.shift_start),
                       minutes_since_midnight(inspector.shift_end))
        routing.AddVariableMinimizedByFinalizer(start)

    # --- Capacity dimension: cap stops per inspector and BALANCE the load ---
    def one(_from_index: int) -> int:
        return 1
    count_cb = routing.RegisterUnaryTransitCallback(one)
    max_cap = max(i.max_stops for i in inspectors)
    routing.AddDimensionWithVehicleCapacity(
        count_cb, 0, [i.max_stops for i in inspectors], True, "Count")
    count_dim = routing.GetDimensionOrDie("Count")
    # Penalizing the largest route span is what evens out workloads.
    count_dim.SetGlobalSpanCostCoefficient(100)

    # --- Discipline: forbid a stop on a vehicle that can't serve it ---
    for node in range(1, n_nodes):
        s = stops[node - 1]
        idx = manager.NodeToIndex(node)
        allowed = [v for v, insp in enumerate(inspectors)
                   if s.discipline in insp.disciplines or s.discipline == "general"]
        routing.SetAllowedVehiclesForIndex(allowed, idx)
        # Let the solver DROP a stop rather than fail — with a stiff penalty.
        routing.AddDisjunction([idx], 1_000_000)

    params = pywrapcp.DefaultRoutingSearchParameters()
    params.first_solution_strategy = routing_enums_pb2.FirstSolutionStrategy.PATH_CHEAPEST_ARC
    params.local_search_metaheuristic = (
        routing_enums_pb2.LocalSearchMetaheuristic.GUIDED_LOCAL_SEARCH)
    params.time_limit.FromSeconds(time_budget_seconds)

    solution = routing.SolveWithParameters(params)
    if solution is None:
        return None
    return _extract_routes(routing, manager, solution, inspectors)

Three design decisions carry the municipal reality. SetGlobalSpanCostCoefficient on the count dimension is the workload-balancing lever: it charges the objective for the difference between the busiest and lightest routes, so the solver trades a little extra travel for a fairer board. SetAllowedVehiclesForIndex turns discipline from a soft preference into a hard rule — an electrical rough can only land on a licensed inspector. And AddDisjunction with a large penalty lets the solver drop an impossible stop rather than return no solution at all: on a day with more demand than capacity, you get a full route for everyone plus an explicit list of what didn’t fit, instead of a blank board at 7 a.m.

Permalink to this section Step 5 — Extract Routes and Report the Overflow

The solver returns an assignment object; walk each vehicle’s chain of nodes to recover the ordered stop list and, critically, surface every stop the disjunction dropped so a dispatcher can act on it.

def _extract_routes(routing, manager, solution, inspectors) -> dict[str, list[int]]:
    routes: dict[str, list[int]] = {}
    for v, inspector in enumerate(inspectors):
        index = routing.Start(v)
        ordered: list[int] = []
        while not routing.IsEnd(index):
            node = manager.IndexToNode(index)
            if node != 0:                  # skip the depot marker
                ordered.append(node - 1)   # back to 0-based stop index
            index = solution.Value(routing.NextVar(index))
        routes[inspector.name] = ordered
    return routes


def unplaced_stops(routes: dict[str, list[int]], stop_count: int) -> list[int]:
    """Stops the solver had to drop — these need manual rescheduling or overflow capacity."""
    placed = {i for route in routes.values() for i in route}
    return [i for i in range(stop_count) if i not in placed]

An unplaced stop is not an error; it is the honest signal that the day is oversubscribed. Feed that list back to the calendar layer to reschedule, or trigger overflow capacity, rather than quietly running short and discovering the miss when a resident calls to ask where the inspector was.

Permalink to this section Configuration Reference

Parameter Type Default Municipal-context notes
avg_speed_kmh float 38.0 Conservative urban blend; too high and every route runs late after the first stop.
time_budget_seconds int 10 Solver wall-clock cap. 5–15 s fits a morning dispatch run; raise only if quality plateaus.
slack_max int 60 Minutes an inspector may wait at a stop before a window opens; larger absorbs early arrivals.
span_cost_coefficient int 100 Workload-balance weight. Higher evens routes at the cost of a little more total travel.
disjunction_penalty int 1_000_000 Cost of dropping a stop; keep it far above any realistic travel cost so drops are a last resort.
max_stops (per inspector) int 12 Hard capacity per route; set from shift length ÷ average visit time, not a guess.
first_solution_strategy enum PATH_CHEAPEST_ARC Fast, sane starting tour; SAVINGS can start closer to optimal on clustered demand.

Permalink to this section Error Handling and Edge Cases

Route optimization fails in municipal-specific ways that a generic VRP tutorial never mentions. Handle each explicitly.

  • Infeasible time windows. A resident’s window is 8–9 a.m. but travel from the depot plus prior stops cannot reach it in time. Without a disjunction the whole solve returns None and dispatch is blank. Keep AddDisjunction on every stop so the solver drops the unreachable one and still delivers a board; alert on the dropped-stop count so a human reschedules it.
  • Oversubscribed day. Total demand exceeds fleet capacity. This is normal, not exceptional — surface unplaced_stops() prominently and route it to overflow scheduling rather than silently truncating routes at max_stops.
  • Missing or bad geocodes. A stop arrives with (0.0, 0.0) or a swapped lat/lon, and the matrix places it in the ocean. Validate coordinates against the jurisdiction’s bounding box before building the matrix and quarantine outliers instead of letting one bad point distort every distance.
  • Clock and timezone drift. Windows expressed in local time break twice a year at DST boundaries and whenever a stop’s timezone differs from the depot’s. Normalize the whole day to one axis and defer the messy cases to the dedicated guide on handling timezone and DST edge cases in inspection scheduling.
  • Solver returns nothing under a tight budget. On a hard instance with a 2-second budget, SolveWithParameters can return None even when a solution exists. Fall back to the nearest-neighbor baseline so dispatch always has a board, and log the timeout so you can raise the budget for that day size.

Permalink to this section Testing and Verification

Routing logic must be tested as data — a regression here silently sends inspectors on longer routes, which nobody notices until the monthly throughput dips. Assert the properties that matter: feasibility, window compliance, and that the solver never loses to the baseline.

import pytest


@pytest.fixture
def tiny_day():
    from datetime import time
    stops = [
        Stop("P-1", 40.71, -74.00, 30, time(9), time(11)),
        Stop("P-2", 40.73, -73.99, 30, time(9), time(12)),
        Stop("P-3", 40.68, -74.02, 45, time(13), time(15), discipline="electrical"),
    ]
    inspectors = [
        Inspector("Ana", 40.70, -74.01, time(8), time(17), max_stops=5,
                  disciplines=frozenset({"general", "electrical"})),
        Inspector("Ben", 40.72, -73.98, time(8), time(17), max_stops=5),
    ]
    return stops, inspectors


def test_electrical_stop_only_goes_to_licensed_inspector(tiny_day):
    stops, inspectors = tiny_day
    matrix = build_time_matrix((40.70, -74.01), stops, avg_speed_kmh=38)
    routes = solve_routes(matrix, stops, inspectors, time_budget_seconds=3)
    assert routes is not None
    # Stop index 2 is electrical; Ben (index 1) is not licensed for it.
    assert 2 not in routes["Ben"]


def test_solver_never_worse_than_greedy(tiny_day):
    stops, inspectors = tiny_day
    depot = (40.70, -74.01)
    matrix = build_time_matrix(depot, stops)
    greedy = nearest_neighbor_routes(matrix, stops, inspectors)
    solved = solve_routes(matrix, stops, inspectors, time_budget_seconds=5)
    assert solved is not None
    assert _total_travel(matrix, solved) <= _total_travel(matrix, greedy)

A passing discipline test plus a “never worse than greedy” invariant is a tight regression net: change the objective weights or a callback and the suite tells you immediately whether you broke feasibility or quality.

Permalink to this section Integration Notes

This optimizer sits in the middle of the field-operations pipeline and only works if the data around it is clean. Its input is the set of due inspections that the scheduling layer produces when it keeps inspector calendars synced with permit milestones — the optimizer should never invent demand, only order it. Its per-stop travel costs are as good as the proximity model behind them, so pair it with the deeper treatment of routing inspectors by geographic proximity when haversine stops being accurate enough. Upstream, the permits being inspected are issued and guarded by the core architecture track: the inspection records this optimizer schedules against are protected by the same role-based access model for clerk portals, so a dispatcher’s ability to reassign a route is itself a scoped permission. And when demand arrives in bulk overnight, it should flow through the shared error handling and retry logic for ingestion pipelines rather than landing unvalidated on the morning board.

Permalink to this section Frequently Asked Questions

Permalink to this section Do I need OR-Tools, or is nearest-neighbor good enough?

For a handful of stops per inspector with loose windows, the greedy baseline in Step 3 is genuinely fine and has near-zero dependency weight. OR-Tools earns its place the moment appointment windows tighten, workloads must be balanced across a fleet, or discipline constraints make some assignments illegal — a greedy pass can’t reason about those globally. The full trade-off, including runtime and dependency footprint on a constrained municipal server, is in the OR-Tools versus greedy comparison.

Permalink to this section How do I keep one inspector from getting all the easy downtown stops?

Use the span-cost lever. SetGlobalSpanCostCoefficient on the count dimension charges the objective for the gap between the busiest and lightest routes, so the solver spreads stops even when clustering them would shave a few minutes of travel. Tune the coefficient up if the board still looks lopsided, accepting a small increase in total travel as the price of fairness.

Permalink to this section What happens on a day with more inspections than the fleet can reach?

The solver drops the stops it cannot place inside their windows — that is what the disjunction penalty enables — and returns full routes plus an explicit unplaced list. Treat that list as the primary output on busy days: feed it back to the scheduling layer for rescheduling or trigger overflow capacity, rather than silently truncating routes and discovering the miss when a resident calls.

Permalink to this section Why round travel times up instead of to the nearest minute?

Because underestimating travel is asymmetric in cost. A one-minute overestimate wastes a little slack; a one-minute underestimate propagates down the route and can push a later stop past its promised window, wasting an entire trip. Rounding up biases the plan toward keeping appointment promises, which is the metric residents and supervisors actually judge dispatch on.