Optimizing technician routes for multi-site maintenance windows

Dispatching one crew across a group of towers in a single working day sounds like a travelling-salesman shortest-path exercise, but in telecom tower operations the shortest path is almost never the legal one. Each site opens only inside a lease-mandated access window, closes again at a municipal quiet-hour curfew, and may be barred entirely by wind or lightning at climb height. This page shows how to turn those contractual and regulatory constraints into an executable route builder: given a set of sites, a travel-time matrix, and a weather feed, it emits an ordered, dispatch-ready sequence in which every stop is provably inside its window and every decision carries an audit hash. It is the concrete, run-it-today implementation behind the constraint scoring described in Technician Assignment Algorithms, which decides who is dispatched; here we solve in what order a chosen crew visits their assigned towers without violating a single window.

Prerequisites & Context

You need Python 3.10 or newer (the code uses dataclass defaults and modern type hints) and no third-party packages — everything here runs on the standard library. Before route optimization is even attempted, three upstream inputs must already be resolved:

  • Access windows and curfews per site. Lease covenants define the earliest and latest legal entry time; municipal noise ordinances define a hard cut-off. These are the temporal hard stops the router enforces. The cadence that puts a site on today’s list at all comes from Frequency Logic & Threshold Tuning, and if you need to derive those intervals from structural age and load, see Dynamic Inspection Frequency Calculation Based on Tower Age and Load.
  • A weather verdict per site. The router treats weather as a boolean climb gate. Producing that verdict from real forecast data — sustained wind, lightning proximity, precipitation — belongs to Weather Window Optimization; the sibling walkthrough Integrating NOAA Weather APIs for Safe Tower Climb Scheduling shows how to fetch and threshold NWS grid-point forecasts.
  • A travel-time matrix. Minutes between every ordered pair of sites, ideally time-dependent so rush-hour segments cost more. The router reads it as a lookup and never recomputes geography inline.

With those in hand, this task sits inside the wider Intelligent Inspection Scheduling & Technician Routing pipeline as the step that converts a validated, weather-cleared work list into a time-stamped itinerary. Formally it is a Time-Dependent Vehicle Routing Problem with Time Windows (TDVRPTW) where curfews add hard departure bounds; travel-time minimization is a soft objective ranked below the hard window and safety constraints.

One-day route timeline: lease windows, travel, service and curfew A horizontal day timeline with an hour axis from 06:00 to 12:00 and a distant 20:00 curfew line. Three tower lanes each show a lease access window bar. TWR-3390's window (06:00 to 06:45) closes before the 07:00 arrival, marked as a window violation. TWR-8842's window (07:00 to 11:00) accepts a 07:10 arrival and a 45-minute service block to 07:55, booked. TWR-4471's window (08:00 to 12:00) is struck through because a 47 mph forecast trips the weather climb gate before any travel is computed. 06:00 07:00 08:00 09:00 10:00 11:00 12:00 depot leaves 06:30 20:00 curfew TWR-3390 TWR-8842 TWR-4471 arrives 07:00 — window shut at 06:45 · WINDOW_VIOLATION 45 min book 07:10–07:55 · hash 1b69…839b 47 mph climb gate trips first — no travel computed · WEATHER_EXCLUSION lease access window travel service block weather-excluded curfew / violation

Step-by-Step Implementation

Step 1 — Model each site as a hard-constraint record. Capture the lease window, the curfew, and the on-site service duration. Keeping these on the data object means the constraint check never has to reach back into external state.

python
@dataclass
class Site:
    site_id: str          # e.g. "TWR-8842"
    window_start: datetime
    window_end: datetime
    curfew: datetime
    service_min: int = 45

Step 2 — Order the work by window opening, not by distance. Sorting candidates by window_start makes the sweep deterministic and mirrors how a crew actually chases opening windows through the day. A nearest-neighbour ordering would routinely arrive before a lease permits entry.

python
for site in sorted(sites, key=lambda s: s.window_start):
    ...

Step 3 — Apply the weather gate first. Weather exclusion is the cheapest constraint to evaluate and the most absolute — an unsafe site can never be booked, so short-circuit before spending travel time on it.

python
def unsafe(forecast: dict) -> bool:
    # OSHA high-wind climb stop (~40 mph) + lightning-proximity gate.
    return forecast.get("wind_mph", 0) > 40 or forecast.get("lightning_pct", 0) > 15

Step 4 — Compute arrival from the running clock and validate the window. Add the travel-matrix cost to the current clock, then reject the stop unless the arrival lands inside the lease window and before the curfew. Both conditions are hard stops; failing either drops the site to a logged skip rather than a forced booking.

python
arrival = clock + timedelta(minutes=travel_min.get(site.site_id, 60))
if not (site.window_start <= arrival <= site.window_end) or arrival >= site.curfew:
    log.warning("skip %s WINDOW_VIOLATION arrival=%s",
                site.site_id, arrival.strftime("%H:%M"))
    continue

Step 5 — Hash every accepted stop for audit immutability. Each booked segment gets a SHA-256 fingerprint over its site, arrival, and departure. When a municipal auditor later asks why a crew was on a tower at 07:10, the hash ties the dispatch record to the exact times that were computed, and any tampering changes the digest.

python
def audit_hash(site_id, arrival, departure) -> str:
    payload = json.dumps({"site": site_id, "in": arrival.isoformat(),
                          "out": departure.isoformat()}, sort_keys=True)
    return hashlib.sha256(payload.encode("utf-8")).hexdigest()[:16]

Step 6 — Advance the clock and continue the sweep. After booking, roll the clock forward to the departure time (arrival plus service duration) so the next site’s arrival is computed from where the crew actually is, keeping the itinerary internally consistent.

Complete Runnable Example

The diagram below traces the full sweep; the code under it is self-contained and runs on a stock Python 3.10+ interpreter with no installs. It carries the three mandatory pieces for this codebase — structured logging, a custom exception class, and a SHA-256 audit hash — and drives them with realistic identifiers (TWR-8842, TWR-4471, TWR-3390).

Route-build sweep with weather and time-window hard stops A vertical flowchart. Sites sorted by entry window flow into a loop node that takes the next site. A weather diamond gate skips unsafe sites as weather exclusions and returns to the loop. Passing sites compute arrival from the travel matrix, then a window-and-curfew diamond gate skips out-of-window sites as window violations and returns to the loop. Sites that clear both gates build a segment with a SHA-256 hash, append to the route, and loop back; when the sweep completes, the route is dispatched with retry backoff. no yes route ready yes no next site Sites sorted by entry window Take next site in sequence Wind > 40 mph or lightning > 15%? Skip · weather exclusion Compute arrival from travel matrix In lease window & before curfew? Skip · window violation Build segment + SHA-256 hash Append to route Dispatch with retry backoff

Figure: TDVRPTW route build with weather and time-window hard stops.

python
import hashlib, json, logging
from dataclasses import dataclass
from datetime import datetime, timedelta, timezone
from enum import Enum

logging.basicConfig(level=logging.INFO, format="%(levelname)s %(message)s")
log = logging.getLogger("route_optimizer")

class RouteError(Enum):
    WEATHER_EXCLUSION = "WEATHER_EXCLUSION"
    WINDOW_VIOLATION = "WINDOW_VIOLATION"

class RoutingError(Exception):
    """Raised when no assigned site yields a legal in-window assignment."""
    def __init__(self, kind: RouteError, site_id: str):
        super().__init__(f"[{kind.value}] {site_id}")
        self.kind, self.site_id = kind, site_id

@dataclass
class Site:
    site_id: str
    window_start: datetime   # lease-mandated earliest access
    window_end: datetime     # lease-mandated latest entry
    curfew: datetime         # municipal quiet-hour hard stop
    service_min: int = 45

def audit_hash(site_id, arrival, departure) -> str:
    payload = json.dumps({"site": site_id, "in": arrival.isoformat(),
                          "out": departure.isoformat()}, sort_keys=True)
    return hashlib.sha256(payload.encode("utf-8")).hexdigest()[:16]

def unsafe(forecast: dict) -> bool:
    return forecast.get("wind_mph", 0) > 40 or forecast.get("lightning_pct", 0) > 15

def build_route(sites, start_at, travel_min, weather):
    route, clock = [], start_at
    for site in sorted(sites, key=lambda s: s.window_start):
        if unsafe(weather.get(site.site_id, {})):
            log.warning("skip %s %s", site.site_id, RouteError.WEATHER_EXCLUSION.value)
            continue
        arrival = clock + timedelta(minutes=travel_min.get(site.site_id, 60))
        if not (site.window_start <= arrival <= site.window_end) or arrival >= site.curfew:
            log.warning("skip %s %s arrival=%s", site.site_id,
                        RouteError.WINDOW_VIOLATION.value, arrival.strftime("%H:%M"))
            continue
        departure = arrival + timedelta(minutes=site.service_min)
        h = audit_hash(site.site_id, arrival, departure)
        route.append((site.site_id, arrival, departure, h))
        log.info("book %s %s-%s hash=%s", site.site_id,
                 arrival.strftime("%H:%M"), departure.strftime("%H:%M"), h)
        clock = departure
    return route

if __name__ == "__main__":
    tz = timezone.utc
    at = lambda h, m=0: datetime(2026, 7, 3, h, m, tzinfo=tz)
    sites = [
        Site("TWR-3390", at(6), at(6, 45), curfew=at(20)),   # window too narrow
        Site("TWR-8842", at(7), at(11), curfew=at(20)),      # bookable
        Site("TWR-4471", at(8), at(12), curfew=at(20)),      # weather-excluded
    ]
    travel = {"TWR-3390": 30, "TWR-8842": 40, "TWR-4471": 55}
    weather = {"TWR-4471": {"wind_mph": 47}}
    route = build_route(sites, start_at=at(6, 30), travel_min=travel, weather=weather)
    print(f"{len(route)} site(s) scheduled")

Verification & Expected Output

Save the block as route.py and run python3 route.py. With the demo inputs above you should see exactly:

text
WARNING skip TWR-3390 WINDOW_VIOLATION arrival=07:00
INFO book TWR-8842 07:10-07:55 hash=1b69810fb10e839b
WARNING skip TWR-4471 WEATHER_EXCLUSION
1 site(s) scheduled

Read it top to bottom: TWR-3390 is swept first because its window opens earliest, but the crew leaves the depot at 06:30 and 30 minutes of travel puts arrival at 07:00 — past the site’s 06:45 latest-entry bound, so it is skipped as a window violation. TWR-8842 is reached at 07:10 (06:30 + 40 minutes), lands inside its 07:00–11:00 window and well before the 20:00 curfew, and is booked with a 45-minute service block ending 07:55. TWR-4471 never gets a travel computation at all because its 47 mph forecast trips the weather gate first. The hash=1b69810fb10e839b value is deterministic for these exact times; if you re-run and the digest differs, an input time changed. A common failure signature is every site logging WINDOW_VIOLATION — that almost always means start_at is later than the day’s windows or your travel matrix is in seconds where the code expects minutes.

Gotchas & Edge Cases

  • Curfews that cross midnight. A quiet-hour cut-off expressed as 22:00 becomes ambiguous when service can spill past midnight into the next calendar day. Always store windows and curfews as timezone-aware datetime objects on a concrete date (as the demo does with timezone.utc), never as naive time values — comparing a next-day 00:30 arrival against a same-day 22:00 curfew silently books an illegal stop otherwise.
  • Multi-jurisdiction timezone drift. A regional route can straddle two timezones, and a site whose lease is written in local time will validate incorrectly if the running clock is in another zone. Normalize every window, curfew, and the depot start time to a single reference zone (UTC internally, converting for display) before the sweep, or a tower an hour east appears to open an hour “late.”
  • Missing travel-matrix keys. The travel_min.get(site.site_id, 60) fallback quietly assumes 60 minutes for any pair it does not know. That is a safety net, not a feature: a missing key means your geography source dropped a segment, and a wrong 60-minute guess can push an arrival just past a tight window. Log every fallback and treat a matrix miss as a data-quality alert, not a silent default.
  • Greedy ordering is not optimal. Sorting by window_start is a fast, explainable heuristic, and for the handful of sites a single crew covers in a day it is usually enough. Once site counts climb into the dozens or windows overlap heavily, hand this same constraint model to a dedicated solver such as OR-Tools, which searches the combinatorial space instead of sweeping it once — the hard-constraint definitions carry over unchanged.

FAQ

How do weather exclusions affect route feasibility?

A weather-excluded site is removed from the itinerary entirely for that window — it is not merely deferred to later in the day. Because the exclusion is evaluated before any travel-time computation, an unsafe tower never consumes a slot or shifts the running clock. If a required inspection is weather-gated out of its last legal window, that is a compliance event: it should be escalated to a fresh scheduling cycle rather than forced into a marginal gap. The boolean verdict itself is produced upstream in Weather Window Optimization from live forecast thresholds.

Why order sites by access-window opening instead of shortest distance?

Distance-first ordering optimizes a soft objective (travel time) while ignoring the hard ones (lease windows and curfews), so it routinely proposes arrivals before a site is legally open or after it has closed. Sweeping by window_start keeps the itinerary feasible by construction and makes every skip explainable to an auditor. Travel minimization still matters, but it is applied as a tie-breaker within the set of legal orderings, never above them.

What does the SHA-256 hash on each stop actually prove?

It binds a dispatch record to the exact site, arrival, and departure times the router computed. If any of those values are altered after the fact — in a downstream system, an exported report, or a manual edit — the recomputed digest no longer matches, so the tamper is detectable. It is an integrity check for the audit trail, not encryption: the times themselves stay readable, but their authenticity is verifiable months later during a municipal or lease review.

Related pages