Routing Inspectors by Geographic Proximity with Python

This guide sits under Syncing Inspector Calendars with Permit Milestones in the Inspection Scheduling & Field Operations track, and covers the step before a slot is booked: choosing which inspector should take each of the day’s inspections based on where the work is.

Permalink to this section The Focused Problem

Every morning a jurisdiction has a bag of geocoded inspections — a footing here, a final across the county, a re-inspection two blocks from the office — and a roster of inspectors who each have a home base, a working district, and a finite day. Assigning them well is the difference between an inspector completing nine jobs and completing six. The compliance stakes are real: an inspection that a code requires within a fixed window but that never gets assigned to anyone with capacity nearby becomes a missed statutory deadline, and a contractor’s project stalls with the jurisdiction on the hook.

This guide solves one narrow slice of that: greedy nearest-available assignment. Given a list of pending inspections with parcel coordinates and a roster of inspectors with home coordinates, districts, and remaining capacity, produce an assignment that sends each job to the nearest inspector who can still take it. This is deliberately not full route optimization — sequencing an inspector’s assigned stops into the shortest drive is a distinct problem handled in optimizing inspector route scheduling with Python. Proximity assignment decides who; route optimization decides in what order. The input characteristics are forgiving — a few hundred inspections and a few dozen inspectors per day — so an exact distance model beats a heavyweight solver here.

The one hard requirement is that the coordinates be trustworthy. A parcel geocoded to the county centroid instead of its real lot will route an inspector thirty miles wrong, so this layer assumes coordinates validated against authoritative parcel geometry, as established when mapping municipal zoning overlays to GIS data.

Nearest-available inspector assignment pipeline Pending inspections with parcel coordinates and a district tag flow left to right. A districting filter drops inspectors whose assigned district does not cover the parcel. A haversine distance step computes great-circle distance from each remaining inspection to each eligible inspector base. A greedy assignment iterates inspections ordered by least schedule slack, assigns each to the nearest inspector with capacity remaining, and decrements that inspector's capacity. The output is an assignment map plus an overflow list of inspections that found no eligible inspector with capacity. in district distances assigned Pending jobs lat / lon district tag Districting filter drop out-of-district eligible set Haversine distance great-circle km job → base Greedy assign nearest w/ capacity decrement load Assignment map job → inspector Overflow list no capacity → escalate

Permalink to this section Step 1 — Compute Great-Circle Distance With Haversine

The parcel and the inspector base are latitude/longitude pairs, so straight-line “Euclidean” distance on raw degrees is wrong — a degree of longitude shrinks toward the poles. The haversine formula gives the great-circle distance on a sphere, which is accurate to well within a percent at county scale and needs no external service.

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

EARTH_RADIUS_KM = 6371.0088


def haversine_km(lat1: float, lon1: float, lat2: float, lon2: float) -> float:
    """Great-circle distance in kilometers between two WGS84 points."""
    d_lat = radians(lat2 - lat1)
    d_lon = radians(lon2 - lon1)
    a = (sin(d_lat / 2) ** 2
         + cos(radians(lat1)) * cos(radians(lat2)) * sin(d_lon / 2) ** 2)
    return 2 * EARTH_RADIUS_KM * asin(sqrt(a))

If you prefer a maintained implementation, geopy.distance.geodesic uses the more precise Vincenty/Karney ellipsoidal model and is worth the dependency when a jurisdiction spans a large latitude range; for most municipal districts the haversine result is indistinguishable and far faster in a tight assignment loop. Straight-line distance is a proxy for drive time, not a substitute — that gap is what the route-optimization stage closes.

Permalink to this section Step 2 — Filter by District Before Measuring

Proximity alone is not authority. An inspector certified for the north district should not be dispatched to a south-district parcel just because they happen to live closer, and some jurisdictions bind specialties (electrical, commercial) to specific staff. Apply a districting filter first so the distance calculation only ever ranks eligible inspectors.

from dataclasses import dataclass, field


@dataclass(slots=True)
class Inspector:
    inspector_id: str
    base_lat: float
    base_lon: float
    districts: frozenset[str]        # districts this inspector may serve
    capacity: int                    # remaining inspections for the day


@dataclass(slots=True)
class Inspection:
    permit_id: str
    lat: float
    lon: float
    district: str
    slack_minutes: int               # how soon the statutory window closes


def eligible(inspection: Inspection, roster: list[Inspector]) -> list[Inspector]:
    """Inspectors who may serve this parcel's district and have capacity left."""
    return [i for i in roster
            if inspection.district in i.districts and i.capacity > 0]

Modeling districts as a frozenset per inspector means a staffer who covers two adjacent districts is expressed naturally, and the eligibility test stays a single membership check. Keep the district assigned to each parcel in sync with the authoritative GIS layer rather than a hand-maintained lookup, so a re-districting is a data change, not a code change.

Permalink to this section Step 3 — Assign Greedily by Slack, Then Proximity

The order you process inspections in matters more than the distance metric. Assign the most time-critical jobs first — those whose statutory window is closest to closing — so a low-slack final inspection is never crowded out by a routine footing. Within that ordering, each job goes to the nearest eligible inspector with capacity, whose capacity is then decremented.

def assign(inspections: list[Inspection],
           roster: list[Inspector]) -> tuple[dict[str, str], list[str]]:
    """Return (permit_id -> inspector_id) and a list of unassigned permit_ids."""
    assignment: dict[str, str] = {}
    overflow: list[str] = []
    # Least slack first: the job closest to breaching its window is placed first.
    for job in sorted(inspections, key=lambda j: j.slack_minutes):
        candidates = eligible(job, roster)
        if not candidates:
            overflow.append(job.permit_id)
            continue
        nearest = min(candidates,
                      key=lambda i: haversine_km(job.lat, job.lon,
                                                 i.base_lat, i.base_lon))
        assignment[job.permit_id] = nearest.inspector_id
        nearest.capacity -= 1        # consume the slot so the next job sees it gone
    return assignment, overflow

The overflow list is not an error state to swallow — it is the day’s escalation queue. A job that finds no eligible inspector with capacity must be surfaced to a dispatcher, who can authorize overtime, pull an inspector from a neighboring district, or push the inspection to the next day within its window. Silently dropping overflow is how a jurisdiction misses a deadline it never knew was at risk.

Permalink to this section Step 4 — Hand Off to Route Sequencing

Proximity assignment produces a set of stops per inspector; it does not order them. Feeding an inspector five jobs in random order can add an hour of backtracking. Group the assignment by inspector and pass each group to the sequencing stage, keeping this module’s single responsibility clean.

from collections import defaultdict


def group_by_inspector(assignment: dict[str, str]) -> dict[str, list[str]]:
    routes: dict[str, list[str]] = defaultdict(list)
    for permit_id, inspector_id in assignment.items():
        routes[inspector_id].append(permit_id)
    return dict(routes)

Each grouped route becomes the input to optimizing inspector route scheduling with Python, which sequences the stops and fits them to the calendar slots reserved during syncing inspector calendars with permit milestones.

Permalink to this section Parameter and Flag Reference

Parameter Type Default Notes
distance_model str "haversine" haversine for speed; geodesic (geopy) for wide-latitude counties.
earth_radius_km float 6371.0088 Mean Earth radius; keep consistent so distances are comparable.
order_key str "slack_minutes" Process least-slack jobs first to protect statutory windows.
respect_districts bool True Enforce district eligibility before ranking by distance.
daily_capacity int 8 Per-inspector slot budget; decremented as jobs are assigned.
max_assign_km float 60.0 Hard cap; a job beyond this from every base goes to overflow, not a bad route.
overflow_policy str "escalate" escalate to a dispatcher; never silently drop.

Permalink to this section Common Failure Patterns and Fixes

Permalink to this section Bad coordinates route inspectors miles wrong

A parcel geocoded to a ZIP centroid or the county seat produces a plausible-looking distance that is completely wrong. Validate coordinates against authoritative parcel geometry before assignment and reject or quarantine any point that falls outside the jurisdiction’s bounding box.

JURISDICTION_BBOX = (41.60, -87.95, 42.05, -87.40)  # (min_lat, min_lon, max_lat, max_lon)


def coords_plausible(lat: float, lon: float) -> bool:
    min_lat, min_lon, max_lat, max_lon = JURISDICTION_BBOX
    return min_lat <= lat <= max_lat and min_lon <= lon <= max_lon

Permalink to this section Swapped latitude and longitude

Ingesting (lon, lat) where the code expects (lat, lon) silently sends every inspector to the wrong hemisphere-scale location. Assert the ranges — latitude is [-90, 90], longitude is [-180, 180] — at the ingestion boundary so a transposed pair fails loudly instead of routing quietly.

Permalink to this section Greedy starvation of a remote inspector

Ordering only by distance can pile every job on the one centrally located inspector while a peripheral colleague sits idle. Enforcing the per-inspector capacity cap (Step 3) forces spillover; if load is still lopsided, add a light penalty proportional to an inspector’s already-assigned count so the metric balances proximity against fairness.

Permalink to this section Ties resolved non-deterministically

When two inspectors are equidistant, min returns whichever the iterator yielded first, so runs differ and audits are hard to reproduce. Break ties on a stable key — inspector ID, or lowest current load — so the same inputs always yield the same assignment.

Permalink to this section Overflow treated as success

A job that reaches the overflow list but is never escalated looks assigned to nobody and misses its window. Treat a non-empty overflow list as an actionable alert, not a log line, and gate the day’s dispatch on it being cleared or explicitly deferred.

Permalink to this section Audit and Logging Guidance

Log one structured record per assignment decision: the permit ID, the parcel coordinates used, the chosen inspector, the computed distance, the job’s slack at decision time, and — critically — the runners-up that were eligible but not chosen. That last field is what lets a compliance officer answer “why did this inspection go to a farther inspector?” months later, when the answer is usually “the nearer one was at capacity.” Record every overflow entry with the reason no inspector was eligible, because an overflow that led to a missed statutory deadline is exactly what a records request will scrutinize. Keep these logs for the jurisdiction’s full retention period, and align their schema with the permit system’s audit format so an assignment record joins cleanly to the milestone it scheduled. Never log a citizen’s name alongside coordinates in the routing trail; the permit ID is sufficient and keeps the geographic log free of PII.

Permalink to this section Frequently Asked Questions

Permalink to this section Why haversine instead of real driving distance from a routing API?

Haversine needs no network call, no API key, and no rate-limit budget, and at county scale it ranks candidates the same way a drive-time query would for the purpose of picking the nearest inspector. Reserve true drive-time distances for the route-sequencing stage, where the order of many stops genuinely depends on the road network, not just the count of who is closest.

Permalink to this section How do I keep one central inspector from getting every job?

Enforce a per-inspector daily capacity and decrement it as you assign, which forces spillover to the next-nearest available inspector once someone is full. If distribution still skews, add a small load penalty to the ranking metric so proximity and current workload are balanced rather than proximity alone.

Permalink to this section What should happen when a job matches no eligible inspector?

Route it to an explicit overflow list and escalate to a dispatcher — do not force it onto the least-bad inspector. Overflow usually means a district is understaffed for the day or a parcel’s coordinates are wrong, and both need a human decision: authorize overtime, borrow from a neighboring district, or defer within the statutory window.