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.
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.
@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.
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.
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.
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.
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).
Figure: TDVRPTW route build with weather and time-window hard stops.
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:
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:00becomes ambiguous when service can spill past midnight into the next calendar day. Always store windows and curfews as timezone-awaredatetimeobjects on a concrete date (as the demo does withtimezone.utc), never as naivetimevalues — 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_startis 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
- Parent topic: Technician Assignment Algorithms
- Sibling guide: Integrating NOAA Weather APIs for Safe Tower Climb Scheduling
- Background: Weather Window Optimization
- Background: Frequency Logic & Threshold Tuning
- Section overview: Intelligent Inspection Scheduling & Technician Routing