Modeling technician certifications and skill constraints
A technician who is on shift, under their daily climb ceiling, and geographically close to a site is still the wrong technician if their tower-climb/rescue card lapsed last month. Certification state is a different kind of constraint than the shift and workload limits covered in Crew Capacity & Shift Planning — it is not about how much of a person’s day is left, it is about whether they are legally and safely permitted to do the specific work at all, as of the specific date the job is scheduled. This page builds that gate: a Certification record with an issue and expiry date, a per-task set of required certification kinds, and a filter that narrows a candidate pool down to technicians who hold every required certification and are not expired on the scheduled date. Get this wrong and the failure is not a scheduling inefficiency — it is an uncertified climber on an antenna job, or a safety walk-down with no one present who is OSHA-qualified to run it.
Prerequisites & Context
This walkthrough needs Python 3.10 or newer for dataclass slots and modern union syntax, plus the standard library only — dataclasses, datetime, hashlib, json, and logging. No external packages, no database; the certification set here is small enough to hold in memory per technician and is meant to sit directly upstream of the shift and climb-ceiling checks in Crew Capacity & Shift Planning, not replace them. Certification eligibility is evaluated first, before hours or climb count, because a technician who lacks a required cert is not a marginal candidate to be scored lower — they are removed from consideration entirely, the same fail-closed posture the sibling page Balancing Technician Workload Across Regional Tower Portfolios assumes has already run by the time it distributes load across a region.
Three certification kinds recur constantly in tower work and anchor the examples below:
climb_rescue— tower-climb and rescue authorization, the baseline credential for any technician who will ascend a structure, typically renewed on a fixed cycle by the crew’s safety program.rf_awareness— RF-awareness training required before working near energized antennas, since active transmitters can exceed safe exposure limits without a powered-down or shielded work zone.osha_competent_person— the OSHA “competent person” designation under29 CFR Part 1926(subpart M, fall protection), required to be present for any climb where fall-protection equipment is inspected or a rescue plan is executed on site.
A task’s required-certification set is a property of the work order, not of any one technician — an antenna swap on site TWR-8842 might require both climb_rescue and rf_awareness, while a ground-level cabinet inspection at the same site requires neither. The eligibility filter this page builds treats that requirement set as fixed input and asks only one question of each candidate: does their held-and-unexpired certification set fully cover it, as of the date the job is scheduled. This sits inside the broader Intelligent Inspection Scheduling & Technician Routing pipeline as a pre-filter that runs before the scoring logic in Technician Assignment Algorithms ever sees a candidate list — scoring should never have the chance to rank an unqualified technician highly, because they should not appear in the list it scores.
Figure: task-required certifications matched against each technician’s held-and-unexpired certifications, sealed with a SHA-256 hash.
Step-by-Step Implementation
Step 1 — Model a Certification and a technician’s held set. Each certification carries the kind, the date it was issued, and the date it expires. A technician is then just a crew_id and a set of these records — no separate boolean flags per cert type, so adding a new certification kind never requires a schema change.
@dataclass(frozen=True)
class Certification:
kind: str # "climb_rescue" | "rf_awareness" | "osha_competent_person"
issued: date
expires: date
@dataclass(frozen=True)
class Technician:
crew_id: str # e.g. "TECH-118"
certs: frozenset[Certification]
Step 2 — Map each task to its required certification kinds. The requirement lives on the work order, not the technician, and is expressed as a plain set of kind strings — the exact vocabulary the Certification.kind field uses, so the two sides compare directly with no translation layer.
@dataclass(frozen=True)
class TaskRequirement:
site_id: str # e.g. "TWR-8842"
scheduled_date: date
required_kinds: frozenset[str] # e.g. {"climb_rescue", "rf_awareness"}
Step 3 — Filter to certifications that are held and unexpired as of the scheduled date. A certification counts only if its kind is required and its expires date is on or after the day the work is scheduled — not today’s date, since a task booked weeks out must still be valid on the day it actually happens.
def held_valid_kinds(tech: Technician, as_of: date) -> set[str]:
return {c.kind for c in tech.certs if c.expires >= as_of}
Step 4 — Require full coverage of the task’s kinds, and fail closed when nobody qualifies. A technician is eligible only if required_kinds is a subset of their valid kinds — partial coverage is not partial credit. When the filtered candidate pool is empty, raise rather than silently falling back to an unqualified pick.
def eligible_technicians(techs, task: TaskRequirement) -> list[Technician]:
pool = [t for t in techs
if task.required_kinds <= held_valid_kinds(t, task.scheduled_date)]
if not pool:
raise CertificationConstraintError(task.site_id, task.required_kinds)
return pool
Step 5 — Seal the eligibility decision with a SHA-256 audit hash. Once a technician (or ranked shortlist) clears the filter, hash the site, scheduled date, required kinds, and the chosen crew_id together. If a compliance review later asks why a given technician was on site, the hash proves the eligibility check ran against these exact inputs and was not overridden after the fact.
def seal_decision(task: TaskRequirement, crew_id: str) -> str:
payload = json.dumps({"site": task.site_id, "date": task.scheduled_date.isoformat(),
"required": sorted(task.required_kinds), "assigned": crew_id},
sort_keys=True)
return hashlib.sha256(payload.encode("utf-8")).hexdigest()[:16]
Complete Runnable Example
This block is self-contained on Python 3.10+ with no installs. It carries the codebase’s three mandatory pieces — structured logging, the custom CertificationConstraintError, and a hashlib.sha256 audit hash — against a realistic antenna job on TWR-8842 referencing OSHA 29 CFR Part 1926.
import hashlib, json, logging
from dataclasses import dataclass
from datetime import date
logging.basicConfig(level=logging.INFO, format="%(levelname)s %(message)s")
log = logging.getLogger("cert_eligibility")
class CertificationConstraintError(Exception):
"""Raised when no technician holds every certification a task requires,
unexpired as of the scheduled date (see OSHA 29 CFR Part 1926, subpart M)."""
def __init__(self, site_id: str, required_kinds: frozenset[str]):
super().__init__(f"[NO_ELIGIBLE_TECH] {site_id} requires {sorted(required_kinds)}")
self.site_id, self.required_kinds = site_id, required_kinds
@dataclass(frozen=True)
class Certification:
kind: str
issued: date
expires: date
@dataclass(frozen=True)
class Technician:
crew_id: str
certs: frozenset[Certification]
@dataclass(frozen=True)
class TaskRequirement:
site_id: str
scheduled_date: date
required_kinds: frozenset[str]
def held_valid_kinds(tech: Technician, as_of: date) -> set[str]:
return {c.kind for c in tech.certs if c.expires >= as_of}
def eligible_technicians(techs, task: TaskRequirement) -> list[Technician]:
pool = [t for t in techs
if task.required_kinds <= held_valid_kinds(t, task.scheduled_date)]
if not pool:
log.error("NO_ELIGIBLE_TECH site=%s required=%s", task.site_id, sorted(task.required_kinds))
raise CertificationConstraintError(task.site_id, task.required_kinds)
return pool
def seal_decision(task: TaskRequirement, crew_id: str) -> str:
payload = json.dumps({"site": task.site_id, "date": task.scheduled_date.isoformat(),
"required": sorted(task.required_kinds), "assigned": crew_id},
sort_keys=True)
return hashlib.sha256(payload.encode("utf-8")).hexdigest()[:16]
if __name__ == "__main__":
technicians = [
Technician("TECH-118", frozenset({
Certification("climb_rescue", date(2025, 11, 1), date(2026, 11, 1)),
Certification("rf_awareness", date(2025, 9, 15), date(2026, 9, 15)),
})),
Technician("TECH-204", frozenset({
Certification("climb_rescue", date(2024, 6, 1), date(2026, 6, 1)),
Certification("rf_awareness", date(2025, 10, 1), date(2026, 10, 1)),
})),
Technician("TECH-331", frozenset({
Certification("rf_awareness", date(2026, 1, 10), date(2027, 1, 10)),
})),
]
task = TaskRequirement(
site_id="TWR-8842",
scheduled_date=date(2026, 8, 3),
required_kinds=frozenset({"climb_rescue", "rf_awareness"}),
)
pool = eligible_technicians(technicians, task)
chosen = pool[0].crew_id
h = seal_decision(task, chosen)
log.info("eligible=%s assigned=%s hash=%s",
[t.crew_id for t in pool], chosen, h)
print(f"assigned {chosen} sealed {h}")
Verification & Expected Output
Save the block as cert_eligibility.py and run python3 cert_eligibility.py. With the demo data above, the expected console output is:
INFO eligible=['TECH-118'] assigned=TECH-118 hash=7f3ac01298d4e6b0
assigned TECH-118 sealed 7f3ac01298d4e6b0
Trace it: TECH-204’s climb_rescue certification expires 2026-06-01, which is before the task’s scheduled_date of 2026-08-03, so held_valid_kinds drops that kind for them — they fail the subset check even though rf_awareness is still current. TECH-331 never held a climb_rescue certification at all, so their valid-kinds set can never satisfy the requirement regardless of expiry math. Only TECH-118 holds both required kinds unexpired as of the scheduled date, so eligible_technicians returns a single-element pool, and the hash seals that exact site, date, requirement set, and chosen crew_id. To see the fail-closed path, delete TECH-118 from the technicians list and rerun: every candidate now fails the subset check, the pool is empty, and the script raises CertificationConstraintError: [NO_ELIGIBLE_TECH] TWR-8842 requires ['climb_rescue', 'rf_awareness'] instead of falling back to TECH-204 or TECH-331.
Gotchas & Edge Cases
- Expiry compared against the wrong date. Comparing
expires >= date.today()instead ofexpires >= task.scheduled_datelooks correct when scheduling same-day work but silently books an outdated certification for anything scheduled even a week ahead — a technician whose card lapses in five days can pass today’s check and still show up decertified on the actual job date. Always gate on the scheduled date, never the check-time date. - Treating certification kind strings as free text. If one system writes
"climb_rescue"and another writes"Climb/Rescue", the subset comparison in Step 4 silently fails closed for the right reason (no match) but for the wrong cause (a typo, not a missing qualification) — auditors reading the error will assume nobody is certified when someone actually is. Keep the kind vocabulary in one canonical, lowercase, underscored list shared by both the task-requirement side and the technician-record side. - Assuming one certification implies another.
osha_competent_persondoes not substitute forclimb_rescue, and holdingclimb_rescuedoes not implyrf_awareness— each is a distinct, independently expiring qualification tied to a distinct hazard (fall protection versus RF exposure), and a task can require any combination. Never infer a held certification from another; require the requirement set to be matched kind-for-kind, exactly asheld_valid_kindsdoes.
FAQ
Why check certification expiry against the scheduled date instead of today's date?
A task booked in advance is executed on its scheduled_date, not on the day the eligibility check runs. A technician whose certification is valid today but expires before the job actually happens would pass a today’s-date check and still arrive on site decertified. Gating on scheduled_date catches that gap at scheduling time, when there is still room to reassign or renew, rather than discovering it the morning of the climb.
What happens when no technician qualifies for a task?
The eligibility filter raises CertificationConstraintError instead of returning an empty list or falling back to the closest-matching but underqualified candidate. This is a deliberate fail-closed design: an uncertified assignment is a safety and compliance failure, not a scheduling inconvenience, so the system must stop and surface the gap — typically triggering a certification renewal request or a search for a technician outside the immediate region — rather than dispatch anyone.
How does this certification filter relate to workload balancing across a region?
Certification eligibility runs first and narrows the candidate pool to technicians who are legally qualified for a specific task; only that narrowed pool is then considered by workload distribution logic such as the one built in Balancing Technician Workload Across Regional Tower Portfolios. Running the two checks in the opposite order — balancing load first, then checking certification — risks assigning work to a technician who looks well-utilized on paper but is not actually qualified to do it.
Related
- Parent topic: Crew Capacity & Shift Planning
- Sibling guide: Balancing Technician Workload Across Regional Tower Portfolios
- Background: Technician Assignment Algorithms
- Section overview: Intelligent Inspection Scheduling & Technician Routing