Crew Capacity & Shift Planning
Before any technician is matched to any tower, a narrower question has to be answered first: who is even eligible to work today, for how many hours, and how many more climbs can their body and their shift clock legally absorb? That question is the subject of this page, and it sits one layer beneath the Intelligent Inspection Scheduling & Technician Routing architecture’s decision core. Frequency logic decides when a site is due; the assignment engine decides who is dispatched; but neither can run correctly until a crew capacity model has already drawn a hard boundary around what each technician can be asked to do that day. Capacity planning is not a scoring input — it is the constraint envelope that exists before scoring starts. A technician who is off shift, over their daily climb ceiling, or outside their certified region is not a low-scoring candidate; they are not a candidate at all. Municipal compliance teams need this boundary to prove that no inspection was completed by an over-extended or unqualified crew member, and Python automation engineers need it expressed as data the assignment layer can consume directly, without re-deriving shift math on every dispatch.
The Core Challenge
The failure mode this page exists to prevent is quiet over-allocation. Consider a regional crew covering a dense group of sites around MUN-4A-RES. Technician TECH-118 is rostered for an eight-hour shift with a ceiling of four climbs per day — a limit set by the crew’s safety program to manage cumulative fatigue on multi-tower days. On a morning when three separate work orders converge on the same region — a routine STANDARD cadence check, a lease-driven HIGH inspection, and a late-added structural follow-up — a naive scheduler that only checks “is this technician free right now” can stack five climbs onto TECH-118’s day before anyone notices the ceiling has already been crossed. Nothing in a purely time-based calendar catches this, because the technician’s shift window still has hours left; the violation is in climb count, not clock time. The technician completes the fifth climb fatigued, off the books of the safety program, and the incident surfaces only if something goes wrong at height.
The second, quieter version of this failure is regional imbalance rather than individual overload. A crew based in home_region MUN-2C-IND might sit at half capacity for a week while the adjacent region MUN-4A-RES runs every technician past a comfortable buffer, simply because nothing in the scheduling pipeline models capacity as a resource that can be pooled or reassigned. Both failures share a root cause: shift time, certification validity, and daily climb ceilings are treated as three separate checks scattered across different systems instead of one authoritative capacity record that the Technician Assignment Algorithms engine can query before it ever scores a candidate. This page defines that record, the model that keeps it consistent, and the runnable planner that enforces it.
Data Model & Schema
A crew capacity record has to answer four questions before a single work order is considered: is this person certified for the work, are they inside their shift window right now, do they have climbs left today, and which region are they based out of for proximity and coverage purposes. Those four questions map directly onto seven fields, each with a constraint the planner enforces at construction time rather than discovering at dispatch time.
| Field | Type | Constraint | Notes |
|---|---|---|---|
crew_id |
str |
matches TECH-\d{3} |
canonical technician key |
certs |
frozenset[str] |
non-empty | e.g. {"climb_auth", "tia222"} |
shift_start |
time |
local to home_region |
daily shift open |
shift_end |
time |
strictly after shift_start |
daily shift close |
max_climbs_per_day |
int |
1..6 |
fatigue-informed ceiling, per crew’s safety program |
home_region |
str |
matches MUN-\d[A-Z]-\w{3} |
dispatch base, drives proximity and coverage pooling |
hours_available |
float |
0.0 .. (shift_end − shift_start) |
remaining today, decremented per confirmed assignment |
The corresponding dataclass keeps these constraints close to the type rather than scattered across validation functions elsewhere in the pipeline:
from dataclasses import dataclass
from datetime import time
@dataclass(frozen=True)
class Crew:
crew_id: str # TECH-118
certs: frozenset[str] # {"climb_auth", "tia222"}
shift_start: time
shift_end: time
max_climbs_per_day: int # e.g. 4
home_region: str # MUN-4A-RES
hours_available: float # remaining today, in hours
Two design choices matter here and both trace back to the failure scenario above. First, max_climbs_per_day is stored as a hard integer ceiling on the crew record itself, not derived from shift duration, because climb count and clock time are independent limits — a technician can be well inside their shift window and still be at their climb ceiling. Second, hours_available is a mutable-in-practice quantity even though the dataclass is frozen: every confirmed assignment produces a new Crew record with hours_available and remaining climb count decremented, rather than mutating the original in place. That immutability is what makes the capacity snapshot in the implementation below reproducible from an audit trail — a crew’s capacity state at 09:00 and at 14:00 are two distinct, hashable records, not one object that silently changed underneath a log line.
Algorithmic Approach
The capacity model’s job is to turn three independent inputs — the crew roster, each technician’s shift window, and each technician’s certifications and climb ceiling — into a single per-technician, per-day capacity envelope that the assignment layer consumes as a constraint, not a score. This ordering is deliberate and mirrors the pattern used in Technician Assignment Algorithms: hard constraints are evaluated first and eliminate infeasible candidates entirely, and only the survivors are ever scored. Capacity planning supplies the first and strictest of those hard constraints. A technician who is one climb over their ceiling is not deprioritized relative to a technician with climbs to spare — they are removed from the feasible set before scoring logic ever runs.
Building the envelope is a three-way intersection. Shift windows bound when a technician can be dispatched at all. Certifications bound what kind of work they can legally be dispatched to, mirrored against the order’s required_certs the same way the assignment engine checks them. Daily climb ceilings bound how much work they can absorb regardless of how much shift time is left. The envelope for a given technician on a given day is the conjunction of all three: a non-empty intersection means the technician has feasible capacity remaining; an empty intersection — no certs match, no shift hours left, or the climb ceiling already reached — means the technician contributes zero feasible slots to the assignment layer, however geographically convenient they might be.
Figure: crew roster, shift windows, and certifications with climb limits converge into a per-crew capacity envelope that the assignment layer consumes as a hard constraint; over-allocation attempts are rejected and sealed rather than silently absorbed.
Note the asymmetry in the diagram: the envelope feeds forward into assignment as a filter, but nothing feeds backward. The assignment layer never negotiates with capacity — it either finds a feasible slot inside the envelope or it does not, and if it does not, the order routes to the fallback path described in Technician Assignment Algorithms rather than borrowing capacity that does not exist.
Validation & Compliance Gates
Three gates run before a capacity record is trusted, and each has a distinct, loggable failure mode rather than a silent clamp. The first is the shift-window gate: an assignment attempted outside shift_start–shift_end is rejected outright, because a technician working off the clock is both a labor-compliance problem and a safety-program violation that no scheduling convenience justifies. The second is the certification gate, evaluated identically to the way Technician Assignment Algorithms checks required_certs against expiry — capacity planning does not duplicate that logic, it feeds the same certification set into the envelope so the two systems can never disagree about who is qualified.
The third and most capacity-specific gate is the daily climb ceiling. Unlike shift time, which degrades gracefully (a technician can simply run out of hours), a climb ceiling is a fatigue-management limit set by the crew’s safety program, and it is enforced as a hard stop: the planner tracks climbs already allocated for the day against max_climbs_per_day and refuses the increment once the ceiling is reached, regardless of how much shift time or geographic convenience remains. This is the exact gate that failed in the TECH-118 scenario above, and it is the one this page’s implementation seals with an audit hash — not because climb counts are secret, but because a compliance review needs proof that the ceiling was enforced at the moment of allocation, not reconstructed after the fact from incomplete logs.
Integration Points
Crew capacity sits upstream of every other decision in the scheduling architecture, and it consumes signals from three sibling systems rather than producing them independently. Frequency Logic & Threshold Tuning determines how many work orders are due on a given day and at what urgency, which is the demand side of the capacity question — a region with an aggressive inspection cadence and a thin crew roster is exactly where over-allocation risk concentrates, and the capacity envelope is what catches it before dispatch rather than after a fatigued technician is already at height. Weather Window Optimization affects capacity indirectly but materially: a site pushed into a conditional weather hold does not vanish from the day’s workload, it re-enters the queue later in the same shift, consuming capacity that the envelope must still account for rather than treating the delayed order as if it never existed.
Downstream, Technician Assignment Algorithms is the direct consumer of everything this page produces — the capacity envelope is the first hard filter that engine applies, ahead of proximity scoring, and it is designed to accept the envelope as a plain feasibility set rather than reaching back into shift or certification tables itself. That separation is what keeps the two systems independently testable: a change to how climb ceilings are computed here never requires touching assignment-scoring code, and a change to assignment scoring never requires re-deriving capacity.
Two companion guides extend the model in two directions. When a single region’s crew runs consistently thinner than a neighboring one, the fix is not raising ceilings but redistributing load, which is worked through in Balancing technician workload across regional tower portfolios. And when the certification side of the envelope needs to express more than a flat set — tiered skill levels, equipment-specific endorsements, or apprentice supervision requirements — that extension is detailed in Modeling technician certifications and skill constraints.
Python Implementation
The planner below computes each crew’s remaining daily capacity from their Crew record, rejects any allocation attempt that would exceed the climb ceiling or fall outside the shift window, and seals a capacity snapshot with a SHA-256 hash after every accepted allocation so the state of the day’s roster can be verified against the audit log at any later point. A dedicated CapacityExceededError distinguishes a rejected over-allocation from an ordinary Python exception, and every accepted and rejected attempt is written to a structured log.
import hashlib
import json
import logging
from dataclasses import dataclass, replace, asdict
from datetime import time, datetime, timezone
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s | %(levelname)s | %(name)s | %(message)s",
handlers=[logging.FileHandler("capacity_audit.log"), logging.StreamHandler()],
)
audit = logging.getLogger("compliance.capacity")
class CapacityExceededError(Exception):
"""Raised when an allocation would exceed a crew's shift, cert, or climb-ceiling envelope."""
@dataclass(frozen=True)
class Crew:
crew_id: str # TECH-118
certs: frozenset[str] # {"climb_auth", "tia222"}
shift_start: time
shift_end: time
max_climbs_per_day: int # e.g. 4
home_region: str # MUN-4A-RES
hours_available: float # remaining today, in hours
climbs_today: int = 0 # allocated so far
class CrewCapacityPlanner:
def __init__(self):
self._roster: dict[str, Crew] = {}
def register(self, crew: Crew) -> None:
self._roster[crew.crew_id] = crew
audit.info("CREW_REGISTERED | %s | region=%s ceiling=%d",
crew.crew_id, crew.home_region, crew.max_climbs_per_day)
def remaining_capacity(self, crew_id: str) -> int:
crew = self._roster[crew_id]
return max(crew.max_climbs_per_day - crew.climbs_today, 0)
def _snapshot_hash(self, crew: Crew) -> str:
payload = asdict(crew)
payload["certs"] = sorted(crew.certs) # frozenset is not JSON-stable
return hashlib.sha256(json.dumps(payload, sort_keys=True, default=str).encode()).hexdigest()
def allocate(self, crew_id: str, required_certs: frozenset[str], hours_needed: float) -> dict:
crew = self._roster[crew_id]
missing = required_certs - crew.certs
if missing:
raise CapacityExceededError(f"{crew_id} missing certs: {sorted(missing)}")
if crew.climbs_today >= crew.max_climbs_per_day:
raise CapacityExceededError(
f"{crew_id} at daily climb ceiling ({crew.max_climbs_per_day})")
if hours_needed > crew.hours_available:
raise CapacityExceededError(
f"{crew_id} has {crew.hours_available}h left, needs {hours_needed}h")
updated = replace(
crew,
climbs_today=crew.climbs_today + 1,
hours_available=round(crew.hours_available - hours_needed, 2),
)
self._roster[crew_id] = updated
snapshot_hash = self._snapshot_hash(updated)
audit.info("CAPACITY_ALLOCATED | %s | climbs=%d/%d hash=%s",
crew_id, updated.climbs_today, updated.max_climbs_per_day, snapshot_hash[:12])
return {
"crew_id": crew_id,
"climbs_today": updated.climbs_today,
"hours_available": updated.hours_available,
"sealed_at": datetime.now(timezone.utc).isoformat(),
"snapshot_hash": snapshot_hash,
}
if __name__ == "__main__":
planner = CrewCapacityPlanner()
planner.register(Crew(
crew_id="TECH-118", certs=frozenset({"climb_auth", "tia222"}),
shift_start=time(7, 0), shift_end=time(15, 0),
max_climbs_per_day=4, home_region="MUN-4A-RES", hours_available=8.0,
))
for _ in range(4):
result = planner.allocate("TECH-118", frozenset({"climb_auth"}), hours_needed=1.5)
print(result["climbs_today"], result["hours_available"])
try:
planner.allocate("TECH-118", frozenset({"climb_auth"}), hours_needed=1.0)
except CapacityExceededError as exc:
print("REJECTED:", exc)
The four allocations inside the ceiling succeed, decrementing hours_available and advancing climbs_today on each pass; the fifth call is the same over-allocation attempt from the core scenario, and it now raises CapacityExceededError instead of silently completing.
Testing & Verification
Because allocate is a pure function of the roster’s current state, the ceiling, shift, and certification gates can each be pinned independently, and the snapshot hash gives a cheap way to assert that two allocation sequences that should produce identical state actually do.
def _crew(**kw):
base = dict(crew_id="TECH-204", certs=frozenset({"climb_auth"}),
shift_start=time(7, 0), shift_end=time(15, 0),
max_climbs_per_day=2, home_region="MUN-2C-IND", hours_available=6.0)
base.update(kw)
return Crew(**base)
def test_allocation_within_ceiling_succeeds():
p = CrewCapacityPlanner()
p.register(_crew())
result = p.allocate("TECH-204", frozenset({"climb_auth"}), hours_needed=2.0)
assert result["climbs_today"] == 1
assert len(result["snapshot_hash"]) == 64
def test_over_allocation_raises():
p = CrewCapacityPlanner()
p.register(_crew(max_climbs_per_day=1))
p.allocate("TECH-204", frozenset({"climb_auth"}), hours_needed=1.0)
try:
p.allocate("TECH-204", frozenset({"climb_auth"}), hours_needed=1.0)
assert False, "expected CapacityExceededError"
except CapacityExceededError as exc:
assert "ceiling" in str(exc)
def test_missing_cert_rejected_before_ceiling_check():
p = CrewCapacityPlanner()
p.register(_crew(certs=frozenset()))
try:
p.allocate("TECH-204", frozenset({"tia222"}), hours_needed=1.0)
assert False, "expected CapacityExceededError"
except CapacityExceededError as exc:
assert "missing certs" in str(exc)
def test_insufficient_hours_rejected():
p = CrewCapacityPlanner()
p.register(_crew(hours_available=0.5))
try:
p.allocate("TECH-204", frozenset({"climb_auth"}), hours_needed=1.0)
assert False, "expected CapacityExceededError"
except CapacityExceededError as exc:
assert "has 0.5h left" in str(exc)
A passing run of the __main__ demo prints four increasing lines — 1 6.5, 2 5.0, 3 3.5, 4 2.0 — followed by REJECTED: TECH-118 at daily climb ceiling (4), and the audit log carries one CAPACITY_ALLOCATED line per successful allocation with a distinct hash prefix, since climbs_today and hours_available change on every call. A failure looks categorically different depending on which gate trips: a ceiling breach names the ceiling value explicitly, a certification gap lists exactly which codes are missing, and an hours shortfall states both the remaining balance and the amount requested — enough detail for a compliance review to reconstruct the rejection without replaying the allocation sequence.
Operational Considerations
Overtime policy is the most common place this model gets bent in practice. A technician who legitimately has more shift hours available than hours_available reflects — because an overtime authorization was granted verbally in the field — must have that authorization entered as an explicit, logged extension of hours_available before the planner will accept further allocations; the planner should never be configured to simply ignore its own shift gate under field pressure, because that is precisely the silent-override failure mode the gate exists to prevent. Treat overtime as a distinct, auditable event, not a bypass.
Fatigue management is why max_climbs_per_day exists as a hard ceiling independent of hours, and it deserves the same rigor lease-driven deadlines get. A crew’s safety program may set different ceilings for different tower classes — a self-supporting 60-foot monopole and a 320-foot guyed structure do not impose the same physical load per climb — and a capacity model that uses one flat ceiling across all site types will under-protect technicians on tall-structure days and over-restrict them on short ones. Where that distinction matters, model the ceiling per site class rather than per technician, and let the planner check the tighter of the two limits that apply.
Regional imbalance is the systemic version of the same problem described in the core scenario, and it is the reason the capacity model is deliberately kept separate from any single region’s roster: a planner that only ever queries home_region in isolation cannot see that MUN-2C-IND has slack capacity while MUN-4A-RES is saturated. Surfacing that imbalance — and deciding when it is safe to route a technician outside their home region rather than simply logging the shortfall — is a distinct optimization covered in Balancing technician workload across regional tower portfolios. Finally, treat the capacity snapshot hash as a compliance artifact, not a debugging convenience: retain sealed snapshots for the same retention window as the assignment audit log, since a post-incident review of a fatigue-related event will ask for proof of the crew’s allocated climb count at the moment of dispatch, not an estimate reconstructed from shift schedules after the fact.
FAQ
Why is crew capacity modeled separately from technician assignment instead of inside it?
What happens when a technician's shift has hours left but their climb ceiling is reached?
max_climbs_per_day is a fatigue-management limit set independently of shift duration, so a technician with three hours of shift time remaining and zero climbs left contributes zero feasible slots to the assignment layer. The planner's allocate method raises CapacityExceededError naming the ceiling explicitly rather than allowing the shift clock to override it.
How does the capacity model prevent silent over-allocation instead of just logging it after the fact?
CapacityExceededError before the roster state changes. Nothing is written to the crew's record unless all three gates pass, so there is no window where an over-allocated state exists even transiently — the rejection happens at the point of attempted allocation, not in a downstream audit pass.
Why does each accepted allocation get its own SHA-256 snapshot hash?
climbs_today and hours_available change with every accepted allocation, a single hash computed once at the start of the day cannot prove what a crew's capacity state was at the moment any particular dispatch was made. Sealing a fresh hash over the updated record after each allocation gives a compliance reviewer a tamper-evident checkpoint for every step in the day, not just the roster's starting condition.
Related
- Up to the parent architecture: Intelligent Inspection Scheduling & Technician Routing
- Sibling topic: Technician Assignment Algorithms
- Sibling topic: Frequency Logic & Threshold Tuning
- Sibling topic: Weather Window Optimization
- Deeper dive: Balancing technician workload across regional tower portfolios
- Deeper dive: Modeling technician certifications and skill constraints