Balancing technician workload across regional tower portfolios
A regional maintenance program can clear every weather window and every lease access window on a given day and still fail its crews: one technician logs eleven hours of climbs while a colleague thirty miles away sits with an empty schedule and towers that were due for inspection this cycle still unassigned. That imbalance is not a routing failure — it is a capacity-planning failure that routing inherits and cannot fix, because deciding when a crew travels to a tower is meaningless until the amount of work handed to each crew is even in the first place. This page builds that missing step: a capacity-aware balancing pass that runs before routing, inside Crew Capacity & Shift Planning, the part of Intelligent Inspection Scheduling & Technician Routing responsible for making sure a crew’s cycle is physically fillable before Technician Assignment Algorithms ever scores who should be dispatched to what.
Prerequisites & Context
You need Python 3.10 or newer and nothing beyond the standard library — dataclasses, statistics, hashlib, json, and logging cover the entire pass. Two inputs must already exist before this code runs:
- Crew capacity-hours. Each crew’s assignable hour budget for the current cycle, expressed as
capacity_hours, not a raw shift length. A crew’s contracted or regulated hours should already have fatigue margin subtracted before they reach the balancer — the climb-safety expectations behind OSHA 29 CFR Part 1926 assume a technician isn’t scheduled back-to-back climbs for an entire shift, socapacity_hoursis a safe ceiling, not the clock-hours a crew is on the payroll. - A due list of towers with effort and priority. Each tower carries a raw inspection-effort estimate in hours and a priority tier (structural severity, overdue status, or regulatory deadline), typically produced by the cadence logic in Frequency Logic & Threshold Tuning.
Crews and towers both carry a region field, and this pass never reassigns work across a region boundary — a crew based in one service territory does not absorb another territory’s overflow just because it has spare hours; that is a distinct, deliberate escalation, not something a balancing loop should do silently. It is also worth being explicit about what this pass does not decide: having capacity-hours free says a crew has time, not that it is qualified for a given structure. Pair this balancing step with the constraints in Modeling technician certifications and skill constraints before treating an allocation as dispatch-ready — a tower requiring a climb-rescue-certified technician cannot go to whichever crew merely has the least load. Once capacity and eligibility are both satisfied, the itinerary ordering within a crew’s day is the job of Technician Assignment Algorithms, not this pass.
Step-by-Step Implementation
Step 1 — Compute each crew’s available capacity-hours. Model a crew as a small mutable record: a fixed, fatigue-adjusted capacity_hours ceiling and a running assigned_hours total that starts at zero and only grows as towers are placed onto it during the pass.
@dataclass
class Crew:
crew_id: str # e.g. "CRW-11"
region: str # e.g. "north"
capacity_hours: float # fatigue-adjusted budget for the cycle
assigned_hours: float = 0.0
sites: list = field(default_factory=list)
Step 2 — Weight towers by inspection effort and priority tier. Raw effort hours understate what a tower actually costs a crew’s cycle once its priority is considered: a tier-1 structural finding consumes more of a crew’s attention and follow-up than its clock-hours alone suggest, so multiply effort by a tier weight before comparing towers to each other or to capacity.
TIER_WEIGHT = {1: 1.5, 2: 1.15, 3: 1.0}
def weighted_effort(tower: Tower) -> float:
return round(tower.effort_hours * TIER_WEIGHT.get(tower.priority_tier, 1.0), 2)
Step 3 — Run a greedy, least-loaded assignment that respects region and capacity. Process towers from heaviest weighted effort to lightest so the biggest commitments get placed while crews still have room to absorb them, and within each tower’s own region always hand it to whichever eligible crew currently carries the smallest assigned_hours — that is what keeps the allocation from stacking work onto one crew while a peer sits idle.
for tower in sorted(towers, key=lambda t: -weighted_effort(t)):
region_crews = [c for c in crews if c.region == tower.region]
candidates = [c for c in region_crews
if c.assigned_hours + weighted_effort(tower) <= c.capacity_hours]
least_loaded = min(candidates, key=lambda c: c.assigned_hours)
Step 4 — Measure balance with coefficient of variation and max-min spread. A single scalar isn’t enough to know whether an allocation is actually even — the coefficient of variation (population standard deviation divided by the mean) captures relative spread regardless of scale, while max-min spread gives a plain-language “hours apart” figure a shift supervisor can read at a glance.
from statistics import mean, pstdev
def balance_spread(loads: dict) -> dict:
values = list(loads.values())
cv = pstdev(values) / mean(values) if mean(values) else 0.0
return {"cv": round(cv, 4), "max_min_spread": round(max(values) - min(values), 2)}
Step 5 — Seal the allocation with hashlib.sha256. Once every tower has a crew, fingerprint the final {crew_id: assigned_hours} mapping so a supervisor’s manual override, a payroll export, or a later dispute has a fixed reference point: if the hash doesn’t match, the allocation was altered after the fact.
def seal_allocation(loads: dict) -> str:
payload = json.dumps(loads, sort_keys=True)
return hashlib.sha256(payload.encode("utf-8")).hexdigest()[:16]
Complete Runnable Example
The diagram below compares the failure mode this pass exists to prevent against the allocation it actually produces. Without a balancing step, a dispatcher (or a naive “assign to the first crew on the list” script) tends to load the first crew in each region until told otherwise, leaving the second crew idle. The least-loaded pass instead keeps both crews in each region within a fraction of an hour of each other.
Figure: crew workload before and after a capacity-aware balancing pass, across two regions.
import hashlib, json, logging
from dataclasses import dataclass, field
from statistics import mean, pstdev
logging.basicConfig(level=logging.INFO, format="%(levelname)s %(message)s")
log = logging.getLogger("workload_balancer")
class WorkloadBalanceError(Exception):
"""Raised when no crew in a region has capacity left for a required tower."""
def __init__(self, site_id: str, region: str):
super().__init__(f"[CAPACITY_EXHAUSTED] {site_id} region={region}")
self.site_id, self.region = site_id, region
@dataclass
class Crew:
crew_id: str
region: str
capacity_hours: float # fatigue-adjusted budget for the cycle
assigned_hours: float = 0.0
sites: list = field(default_factory=list)
@dataclass
class Tower:
site_id: str
region: str
effort_hours: float
priority_tier: int # 1 = highest priority
TIER_WEIGHT = {1: 1.5, 2: 1.15, 3: 1.0}
def weighted_effort(tower: Tower) -> float:
return round(tower.effort_hours * TIER_WEIGHT.get(tower.priority_tier, 1.0), 2)
def assign_towers(crews: list, towers: list) -> dict:
for tower in sorted(towers, key=lambda t: -weighted_effort(t)):
region_crews = [c for c in crews if c.region == tower.region]
candidates = [c for c in region_crews
if c.assigned_hours + weighted_effort(tower) <= c.capacity_hours]
if not candidates:
log.error("CAPACITY_EXHAUSTED %s region=%s", tower.site_id, tower.region)
raise WorkloadBalanceError(tower.site_id, tower.region)
least_loaded = min(candidates, key=lambda c: c.assigned_hours)
least_loaded.assigned_hours += weighted_effort(tower)
least_loaded.sites.append(tower.site_id)
log.info("assign %s -> %s load=%.2fh", tower.site_id,
least_loaded.crew_id, least_loaded.assigned_hours)
return {c.crew_id: c.assigned_hours for c in crews}
def balance_spread(loads: dict) -> dict:
values = list(loads.values())
cv = pstdev(values) / mean(values) if mean(values) else 0.0
return {"cv": round(cv, 4), "max_min_spread": round(max(values) - min(values), 2)}
def seal_allocation(loads: dict) -> str:
payload = json.dumps(loads, sort_keys=True)
return hashlib.sha256(payload.encode("utf-8")).hexdigest()[:16]
if __name__ == "__main__":
crews = [
Crew("CRW-11", "north", 40.0), Crew("CRW-12", "north", 40.0),
Crew("CRW-21", "south", 36.0), Crew("CRW-22", "south", 36.0),
]
towers = [
Tower("TWR-8842", "north", 6.0, 1), Tower("TWR-3390", "north", 4.5, 2),
Tower("TWR-4471", "north", 5.0, 3), Tower("TWR-9010", "south", 7.0, 1),
Tower("TWR-2205", "south", 3.5, 2), Tower("TWR-6673", "south", 5.5, 3),
]
loads = assign_towers(crews, towers)
spread = balance_spread(loads)
digest = seal_allocation(loads)
print(f"cv={spread['cv']} spread={spread['max_min_spread']}h hash={digest}")
Verification & Expected Output
Save the block as balance.py and run python3 balance.py. With the demo inputs above you should see exactly:
INFO assign TWR-9010 -> CRW-21 load=10.50h
INFO assign TWR-8842 -> CRW-11 load=9.00h
INFO assign TWR-6673 -> CRW-22 load=5.50h
INFO assign TWR-3390 -> CRW-12 load=5.17h
INFO assign TWR-4471 -> CRW-12 load=10.17h
INFO assign TWR-2205 -> CRW-22 load=9.52h
cv=0.0592 spread=1.5h hash=2fe6a767a9ae65b0
Read the order carefully: towers are swept globally by weighted effort, heaviest first, which is why TWR-9010 (a tier-1 south tower weighted to 10.5 hours) is placed before TWR-8842 even though they’re in different regions — the sort doesn’t know or care about region until the candidate filter runs. Each tower is still only ever compared against crews in its own region, so CRW-21 fills first (10.50 hours) while CRW-11 starts fresh in the north. The lighter towers then fall to whichever crew in their region is currently lightest — TWR-3390 and TWR-4471 both land on CRW-12 because it started at zero while CRW-11 was already carrying TWR-8842. The final cv=0.0592 is a well-balanced allocation (a coefficient of variation under roughly 0.10 is a reasonable operational target for four crews); spread=1.5h says the busiest and idlest crew are barely ninety minutes apart. The hash=2fe6a767a9ae65b0 is deterministic for these exact inputs — if you change any effort_hours, priority_tier, or capacity_hours value and the digest doesn’t move, something in the payload construction is silently ignoring that field. A WorkloadBalanceError fires instead of a normal return whenever every crew in a tower’s region is already too loaded to take it — treat that as a signal to add capacity or defer the tower, never to silently overflow a crew past its ceiling.
Gotchas & Edge Cases
- Priority-tier weighting can starve tier-3 towers. Sorting by weighted effort means tier-1 and tier-2 towers always claim capacity first; in a tightly loaded cycle, tier-3 towers can lose their region’s remaining capacity every single cycle and never get scheduled at all. If a tower has been skipped for more than one or two cycles running, escalate its effective tier rather than letting the weighting silently defer it indefinitely — an aging factor that nudges a tower’s priority tier up after repeated deferrals keeps the balancer honest over time.
- Region confinement is a modeling choice, not a law of physics. Real portfolios occasionally need a rare, deliberate cross-region loan — a north crew covering one south tower during a staffing gap, for instance. Do not relax the
region_crewsfilter to “fix” aWorkloadBalanceError; that quietly reintroduces the travel-time and jurisdiction assumptions this pass was built to avoid. Handle an overflow loan as an explicit, logged exception path with its own approval, not a parameter tweak to the greedy loop. capacity_hoursmust already exclude travel and administrative time. If a crew’s number here is a raw contracted-hours figure rather than a fatigue-and-overhead-adjusted ceiling, the balancer will happily fill a crew to a number that looks even on paper but leaves no room for the drive time between towers or the paperwork after each climb. Computecapacity_hoursfrom the same shift-planning input that already accounts for those deductions — never from a bare hours-per-week constant.
FAQ
Why sort towers by weighted effort instead of processing them region by region?
Sorting globally by weighted effort ensures the heaviest, highest-priority commitments are placed while every crew still has the most room to absorb them. If the pass instead worked strictly region by region, the relative order in which regions happen to be listed could let one region’s crews fill up on lighter towers before a heavier tower from another region ever gets considered — the outcome per region is the same either way, but the global heaviest-first order is a simpler invariant to test and reason about.
How is this different from the routing done in Technician Assignment Algorithms?
This pass answers “how much work does each crew carry,” which has to be settled before anyone can meaningfully answer “in what order does a crew visit its towers.” Technician Assignment Algorithms and its routing walkthroughs take a crew’s already-balanced tower list and sequence it against travel time, lease windows, and weather; they never redistribute towers between crews. Handing an unbalanced list to the router just produces a beautifully optimized route for an overloaded crew.
What does the coefficient of variation actually tell a shift supervisor that max-min spread doesn't?
Max-min spread is easy to read but scale-dependent — 1.5 hours apart means something different for crews averaging 4 hours than for crews averaging 40. The coefficient of variation normalizes spread against the mean, so it stays comparable across cycles and regions even as average workload rises or falls with the season. In practice, track both: spread for a quick sanity check, coefficient of variation as the number you actually alert on.
Related
- Parent topic: Crew Capacity & Shift Planning
- Sibling guide: Modeling technician certifications and skill constraints
- Background: Technician Assignment Algorithms
- Section overview: Intelligent Inspection Scheduling & Technician Routing