Reducing Cross-Campus Drive Time with Zone-Partitioned Dispatch

A technician who spends half a shift walking or driving between buildings is not fixing anything. This is the specific failure mode inside Geographic Zone Routing for Maintenance Dispatch: a dispatcher that assigns each work order to whichever technician looks available right now, without regard for where that technician physically is, turns a multi-building campus into a maze the crew re-walks all day. It is one of the most common ways a Technician Routing & SLA Enforcement deployment quietly fails — every individual assignment looks correct in the log, and the aggregate drive time is the thing that actually blows the SLA.

Incident Profile

A regional hospital estate runs six buildings across a shared campus — a boiler plant, a data center, patient towers, and a loading dock — all fed by one dispatch queue. The queue assigns strictly in rotation: whichever technician finished their last job first gets the next ticket, wherever it is. The shift-end fleet report below is what surfaced the problem; it was not a crash, just a slow bleed of SLA minutes that took a manager three weeks to notice in the aggregate numbers.

2026-07-14 07:58:11 INFO  [dispatch_engine] Assigned WO-51102 -> tech=T-118 (round_robin, next-in-queue)
2026-07-14 07:58:11 INFO  [dispatch_engine] tech=T-118 last_location=Building-C-Loading, next_job=Building-H-RoofAHU
2026-07-14 08:41:57 WARNING [sla_monitor] WO-51102 travel_time_min=43 (est=12) — SLA window consumed by transit
2026-07-14 08:42:03 ERROR   [sla_monitor] WO-51102 breached response_sla (window=45m, travel=43m, wrench=0m)
2026-07-14 09:15:44 INFO  [dispatch_engine] Assigned WO-51119 -> tech=T-118 (round_robin, next-in-queue)
2026-07-14 09:15:44 INFO  [dispatch_engine] tech=T-118 last_location=Building-H-RoofAHU, next_job=Building-A-Basement
2026-07-14 10:02:18 WARNING [sla_monitor] WO-51119 travel_time_min=39 (est=10)
2026-07-14 10:02:20 ERROR   [sla_monitor] WO-51119 breached response_sla (window=45m, travel=39m, wrench=6m)
2026-07-14 16:30:00 INFO  [fleet_report] shift_summary tech=T-118 jobs=6 total_travel_min=214 total_wrench_min=187 travel_pct=53.4
2026-07-14 16:30:00 INFO  [fleet_report] shift_summary tech=T-119 jobs=5 total_travel_min=176 total_wrench_min=201 travel_pct=46.7

Two numbers matter: travel_pct=53.4 means technician T-118 spent more of the shift moving between buildings than doing repair work, and both logged work orders breached their response_sla window on transit time alone, before a single tool was touched. Nothing was wrong with either technician’s skill or availability — the dispatcher just never asked where the next job was relative to where they already stood.

Technician travel across routing zones, before and after zone-partitioned dispatch Two stacked timelines plot two technicians' job stops across three routing-zone lanes over an eight-stop shift. In the before panel, round-robin assignment sends both technician T-101 and technician T-102 diagonally across all three zones, each one traversing the full span of the campus, totalling 26.7 minutes of estimated drive time. In the after panel, zone-partitioned dispatch keeps T-101 confined to zone 1 and zone 2 and keeps T-102 confined to zone 3 alone, cutting total estimated drive time to 10.8 minutes, a 59.6 percent reduction. Same eight stops, two dispatch rules: round-robin vs. zone-partitioned BEFORE — round-robin (zone-blind) Zone 1 Zone 2 Zone 3 shift time -> T-101 T-102 Total estimated drive time: 26.7 min AFTER — zone-partitioned batching Zone 1 Zone 2 Zone 3 shift time -> T-101 T-102 Total estimated drive time: 10.8 min -59.6% drive time for the same eight stops

Root Cause Analysis

The CMMS data was fine — every asset carried a resolvable coordinate, and every technician’s location updated correctly after each job. The failure was entirely in the assignment rule, and it compounds from two related habits.

  1. Round-robin ignores geography completely. “Next technician in the queue gets the job” is simple to implement and easy to reason about for fairness, but it has no concept of distance. When two technicians are strictly alternated, both of them end up covering every part of the campus over the course of a shift — the work never separates into “the wing you’re already in” versus “the wing across the parking lot.”
  2. “Nearest available anywhere” without batching has the same blind spot in slow motion. Picking the closest free technician for each job in isolation looks like a fix, but if it never gives extra weight to a technician who is already working the same zone, a string of jobs in one wing will still get split across whichever technicians happen to free up first — the assignment is locally sensible and globally wasteful.
  3. No zone concept exists between the asset tree and the dispatcher. The location data underlying this problem is exactly the same coordinate data used to build asset hierarchy design — each asset already has a resolvable (latitude, longitude). The gap is that nothing groups those coordinates into a routing unit the dispatcher can reason about before it starts ranking individual technicians.

Zone locality is a separate axis from two other constraints this domain already enforces: it has nothing to do with whether a technician is certified for the trade, which is what skill-based dispatch checks, and it has nothing to do with how much SLA budget a job has left, which is what SLA timer escalation tracks. A technician can be perfectly qualified and well within their SLA window and still lose the whole window to a fifty-minute drive the dispatcher never priced in.

Resolution

The fix is not a smarter distance function — the round-robin dispatcher already “knows” where every technician is. The fix is giving the assignment rule a zone to reason about, and a rule that prefers keeping a technician’s jobs batched inside it.

Before — round-robin, zone-blind:

def naive_round_robin(work_orders, technicians):
    """Ignores location entirely: whoever is next in the rotation gets the job."""
    total_km = 0.0
    log = []
    for i, wo in enumerate(work_orders):
        best = technicians[i % len(technicians)]
        km = haversine_km(ASSET_LOCATIONS[best.current_location], ASSET_LOCATIONS[wo.location_id])
        total_km += km
        log.append((wo.work_order_id, best.tech_id, km))
        best.current_location = wo.location_id
    return total_km, log

After — zone-partitioned, batches jobs to whoever already owns the zone:

def nearest_eligible_technician(work_order, technicians, zone_of):
    """Prefer a technician already inside the job's zone; fall back to global nearest."""
    target_zone = zone_of[work_order.location_id]
    same_zone = [t for t in technicians if zone_of[t.current_location] == target_zone]
    pool = same_zone or technicians  # only leave the zone when nobody is already in it
    target = ASSET_LOCATIONS[work_order.location_id]
    return min(pool, key=lambda t: haversine_km(ASSET_LOCATIONS[t.current_location], target))


def zone_partitioned_dispatch(work_orders, technicians, zone_of):
    total_km = 0.0
    log = []
    for wo in work_orders:
        best = nearest_eligible_technician(wo, technicians, zone_of)
        km = haversine_km(ASSET_LOCATIONS[best.current_location], ASSET_LOCATIONS[wo.location_id])
        total_km += km
        log.append((wo.work_order_id, best.tech_id, km))
        best.current_location = wo.location_id  # keeps the batch going for the next same-zone job
    return total_km, log

The change is one preference rule: before ranking every technician by raw distance, filter to whoever is already standing in the job’s zone. Only when that filter comes back empty does the search widen to the whole roster — the same overflow behaviour the parent zone-routing resolver uses when a home zone is empty. Everything else — the haversine estimate, the roster, the work order — is unchanged.

Minimal Reproducible Pipeline

This script runs as-is. It defines the canonical WorkOrderPayload, synthesizes a small nine-building campus with real coordinates, partitions it into three routing zones, and runs the same eight-stop queue through both dispatchers so you can see the drive-time gap directly.

import math
from dataclasses import dataclass, field
from datetime import datetime, timezone
from enum import Enum
from typing import Dict, List, Optional, Tuple


class Priority(str, Enum):
    CRITICAL = "critical"
    HIGH = "high"
    STANDARD = "standard"
    PLANNED = "planned"


@dataclass
class WorkOrderPayload:
    """Canonical CMMS work order — SLA fields are mandatory site-wide."""
    work_order_id: str
    asset_id: str
    part_skus: List[str]
    required_quantities: Dict[str, int]
    location_id: str
    priority: Priority = Priority.STANDARD
    requested_completion: Optional[datetime] = None
    escalation_tier: int = 0
    status: str = "open"
    created_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc))


@dataclass
class Technician:
    tech_id: str
    current_location: str


ASSET_LOCATIONS: Dict[str, Tuple[float, float]] = {
    "Building-A-Basement": (40.440600, -79.995900),
    "Building-B-Mezzanine": (40.440959, -79.994128),
    "Building-C-Loading": (40.440511, -79.988812),
    "Building-D-Boiler": (40.441049, -79.987867),
    "Building-E-Chiller": (40.440331, -79.986922),
    "Building-F-Lobby": (40.440780, -79.980543),
    "Building-G-DataCenter": (40.440241, -79.979598),
    "Building-H-RoofAHU": (40.441139, -79.978653),
}


def haversine_km(a: Tuple[float, float], b: Tuple[float, float]) -> float:
    lat1, lon1 = a
    lat2, lon2 = b
    r = 6371.0
    p1, p2 = math.radians(lat1), math.radians(lat2)
    dphi = math.radians(lat2 - lat1)
    dlmb = math.radians(lon2 - lon1)
    h = math.sin(dphi / 2) ** 2 + math.cos(p1) * math.cos(p2) * math.sin(dlmb / 2) ** 2
    return 2 * r * math.asin(math.sqrt(h))


def estimated_travel_minutes(km: float, avg_kph: float = 9.0) -> float:
    # Campus interior speed: golf cart, stairwells and corridors, not open road.
    return (km / avg_kph) * 60.0


def partition_into_zones(locations: Dict[str, Tuple[float, float]], zone_count: int = 3) -> Dict[str, str]:
    """Longitude-banded split — good enough for a linear or clustered campus.
    Swap for polygon containment or nearest-centroid on a sprawling, irregular estate."""
    ordered = sorted(locations.items(), key=lambda item: item[1][1])
    band_size = math.ceil(len(ordered) / zone_count)
    zone_of: Dict[str, str] = {}
    for idx, (location_id, _coords) in enumerate(ordered):
        zone_index = min(idx // band_size, zone_count - 1)
        zone_of[location_id] = f"zone-{zone_index + 1}"
    return zone_of


def nearest_eligible_technician(work_order: WorkOrderPayload, technicians: List[Technician], zone_of: Dict[str, str]) -> Technician:
    target_zone = zone_of[work_order.location_id]
    same_zone = [t for t in technicians if zone_of[t.current_location] == target_zone]
    pool = same_zone or technicians
    target = ASSET_LOCATIONS[work_order.location_id]
    return min(pool, key=lambda t: haversine_km(ASSET_LOCATIONS[t.current_location], target))


def naive_round_robin(work_orders: List[WorkOrderPayload], technicians: List[Technician]):
    total_km = 0.0
    total_min = 0.0
    log = []
    for i, wo in enumerate(work_orders):
        best = technicians[i % len(technicians)]
        km = haversine_km(ASSET_LOCATIONS[best.current_location], ASSET_LOCATIONS[wo.location_id])
        total_km += km
        total_min += estimated_travel_minutes(km)
        log.append((wo.work_order_id, best.tech_id, best.current_location, wo.location_id, round(km, 3)))
        best.current_location = wo.location_id
    return total_km, total_min, log


def zone_partitioned_dispatch(work_orders: List[WorkOrderPayload], technicians: List[Technician], zone_of: Dict[str, str]):
    total_km = 0.0
    total_min = 0.0
    log = []
    for wo in work_orders:
        best = nearest_eligible_technician(wo, technicians, zone_of)
        km = haversine_km(ASSET_LOCATIONS[best.current_location], ASSET_LOCATIONS[wo.location_id])
        total_km += km
        total_min += estimated_travel_minutes(km)
        log.append((wo.work_order_id, best.tech_id, best.current_location, wo.location_id, round(km, 3)))
        best.current_location = wo.location_id
    return total_km, total_min, log


if __name__ == "__main__":
    zone_of = partition_into_zones(ASSET_LOCATIONS, zone_count=3)

    arrival_order = [
        "Building-A-Basement", "Building-B-Mezzanine", "Building-C-Loading",
        "Building-D-Boiler", "Building-E-Chiller", "Building-F-Lobby",
        "Building-G-DataCenter", "Building-H-RoofAHU",
    ]
    work_orders = [
        WorkOrderPayload(
            work_order_id=f"WO-{510 + i}",
            asset_id=f"AST-{i}",
            part_skus=[],
            required_quantities={},
            location_id=loc,
            priority=Priority.STANDARD,
        )
        for i, loc in enumerate(arrival_order)
    ]

    naive_techs = [Technician("T-101", "Building-A-Basement"), Technician("T-102", "Building-H-RoofAHU")]
    zone_techs = [Technician("T-101", "Building-A-Basement"), Technician("T-102", "Building-H-RoofAHU")]

    naive_km, naive_min, naive_log = naive_round_robin(work_orders, naive_techs)
    zone_km, zone_min, zone_log = zone_partitioned_dispatch(work_orders, zone_techs, zone_of)

    print(f"Round-robin:  total_km={naive_km:.2f}  total_travel_min={naive_min:.1f}")
    print(f"Zone-aware:   total_km={zone_km:.2f}  total_travel_min={zone_min:.1f}")

    reduction = (1 - zone_min / naive_min) * 100
    print(f"Drive-time reduction: {reduction:.1f}% ({naive_min:.1f} min -> {zone_min:.1f} min)")

Running this exact script prints Round-robin: total_km=4.01 total_travel_min=26.7, Zone-aware: total_km=1.62 total_travel_min=10.8, and Drive-time reduction: 59.6% (26.7 min -> 10.8 min) — the same eight work orders, the same two technicians, the only difference is whether the assignment rule checks the zone before it checks raw distance. Assert on the reduction percentage in a test rather than the raw minutes, since avg_kph and real coordinates will vary per site: assert zone_min < naive_min * 0.75 is a reasonable regression guard for any campus shaped like this one.

Prevention Checklist

Frequently Asked Questions

How many zones should a campus be split into?

Enough that each zone is small enough to cross on foot or by cart in a few minutes, and few enough that every zone reliably has at least one technician in it during a shift. A nine-building campus with two technicians per shift, as in the pipeline above, works well with two to three zones; a hundred-building estate needs a zone per technician team, not a zone per building.

Does zone-partitioned dispatch conflict with skill-based dispatch or SLA priority?

No — they run in sequence, not in competition. Zone locality narrows the candidate pool to technicians who are close; skill-based dispatch then filters that pool to who is actually certified for the trade; SLA priority breaks any remaining tie. A zone-local but unqualified technician should never win over a slightly farther one who is certified.

What if the nearest in-zone technician is busy or unqualified?

The fallback in nearest_eligible_technician already handles this: when the in-zone filter returns an empty list, the search widens to the full roster rather than blocking the job. Treat a frequent fallback as a staffing signal — if one zone overflows every shift, it needs a dedicated technician, not a smarter algorithm.

Straight-line haversine distance ignores stairwells, elevators, and one-way corridors — does that matter?

For ranking candidates within a small campus it rarely changes the winner, since the technician who is geometrically closer is almost always also closer by any indoor path. If your estate has a real chokepoint — a single stairwell connecting two towers, for example — model it as a fixed time penalty added after the haversine estimate rather than replacing the estimate with a full pathfinding system; it is far cheaper to maintain and solves the actual problem.

This page resolves a batching failure inside geographic zone routing; for the roster and permission checks that run alongside it see skill-based dispatch and matching technician certifications to work order trades, confirm the transit minutes you save actually protect the deadline in SLA timer escalation, and make sure a zone’s roster reflects who is actually on shift with shift calendar integration.

Part of: Geographic Zone Routing for Maintenance Dispatch.