Condition-Based Maintenance Scheduling for Utility Assets
The Failure Mode: A Calendar That Ignores the Asset
Fixed-interval maintenance fails in two directions at once, and both are expensive. Servicing every asset on the same clock over-maintains the ones that are young, low-stress, and rarely used — burning crew hours on a five-year-old polyethylene lateral in a quiet cul-de-sac — while under-maintaining the ones that actually threaten service: a 1962 cast-iron main under an arterial road, a switchgear cabinet feeding a hospital, a pressure-reducing valve at the head of a whole zone. The calendar treats a critical, aged, corrosion-prone asset and a benign new one identically, because the only variable it reads is elapsed time. When a main breaks or a transformer fails between scheduled visits, the post-incident review always finds the same thing: the data needed to predict the failure existed in the geodatabase, but nothing in the schedule was reading it.
Condition-based maintenance scheduling closes that gap by making the schedule a function of the asset rather than the calendar. Instead of “inspect every valve every three years,” the rule becomes “compute each asset’s risk from its age, material, criticality, and last inspection, then schedule intervention in risk order.” This is the entry discipline of Asset Lifecycle & Maintenance Automation: it sits directly on top of a validated spatial register and feeds the work-management system downstream. This reference targets utility engineers, GIS technicians, and Python automation teams, and it covers the health index, the criticality model, the probability-times-consequence risk score, the inspection feedback loop, and batch scoring across the full asset register. The two computations at its core are worked end to end in risk-scoring utility assets with Python and automating inspection-cycle generation from asset age.
Prerequisite Checklist
Confirm every item below before scoring or scheduling against production data. Skipping the register-validation or criticality checks is the most common cause of a schedule that looks defensible but ranks the wrong assets first.
Core Data Model: Health Index, Criticality, and the Risk Product
Condition-based scheduling rests on three derived quantities, each computed per asset and each traceable back to a source attribute. Getting the model right means keeping them separate: the health index measures how likely the asset is to fail, criticality measures how much it matters if it does, and risk is their product. Collapsing them — ranking by age alone, or by customers-served alone — reproduces exactly the failure mode this discipline exists to fix.
The health index normalizes condition onto a bounded 0-to-100 scale where 100 is as-new and 0 is failed. Its dominant input is age relative to expected service life for the asset’s material class: ductile iron is rated for a longer life than unlined cast iron, PVC longer than asbestos-cement. Age alone is a weak predictor, so the index is corrected by the most recent inspection grade — a 40-year-old main that inspected as sound is healthier than its age implies, and a 15-year-old main with active tuberculation is worse. Where telemetry exists (pressure-transient counts, fault history), it further discounts the index.
Criticality is a consequence weight independent of condition. It rolls up from the asset hierarchy: how many service points are downstream, whether any are critical facilities, the pressure or voltage class, and the cost and duration of a failure at that location. This is where a clean containment model earns its keep — without it, the system cannot answer “how many customers depend on this asset” without re-deriving it from raw geometry on every run.
Risk is the product. A defensible score multiplies a probability-of-failure derived from the health index by that consequence weight, yielding a single number that orders the whole register. The full computation, over a GeoDataFrame, is the subject of risk-scoring utility assets with Python; the model below shows how the health index that feeds it is assembled.
from dataclasses import dataclass
from datetime import date
import logging
logging.basicConfig(level=logging.INFO, format="%(levelname)s: %(message)s")
# Expected service life in years, keyed on material class.
SERVICE_LIFE = {"DI": 100, "CI": 75, "PVC": 80, "PE": 60, "AC": 50, "STEEL": 65}
@dataclass(frozen=True)
class HealthInputs:
"""Per-asset inputs for the health index."""
material: str
install_date: date
inspection_grade: int | None # 1 (new) .. 5 (imminent failure), or None
def health_index(inp: HealthInputs, as_of: date) -> float:
"""Return a bounded 0..100 health index for one asset.
Age against expected service life sets the baseline; the most recent
inspection grade corrects it up or down. Higher is healthier.
"""
life = SERVICE_LIFE.get(inp.material)
if life is None:
raise KeyError(f"unknown material class {inp.material!r}")
age_years = (as_of - inp.install_date).days / 365.25
if age_years < 0:
raise ValueError(f"install_date {inp.install_date} is in the future")
# Baseline: linear consumption of service life, clamped to [0, 100].
baseline = max(0.0, 100.0 * (1.0 - age_years / life))
# Inspection correction: grade 3 is neutral; 1 lifts, 5 depresses.
if inp.inspection_grade is not None:
correction = (3 - inp.inspection_grade) * 12.5 # +25 .. -25
baseline = min(100.0, max(0.0, baseline + correction))
return round(baseline, 1)
if __name__ == "__main__":
old_but_sound = HealthInputs("CI", date(1985, 6, 1), inspection_grade=2)
print(health_index(old_but_sound, date(2026, 7, 13)))
Step-by-Step Implementation
Follow this sequence to take a raw asset register to a risk-ranked, dated maintenance schedule.
- Assemble the register. Join
install_date,material,criticality,customers_served, and the last inspection date and grade onto one validated asset table. Resolve nulls with documented estimation rules before scoring; an undocumented default silently biases the ranking. - Compute the health index. Apply
health_indexper asset to blend age against material service life and correct by the last inspection grade. Persist it as a column so the score is reproducible and auditable. - Score risk. Multiply a probability-of-failure derived from the health index by the consequence weight from criticality and customers served. Batch this across the whole
GeoDataFramerather than looping per feature. - Band and rank. Sort by risk descending and cut the population into intervention bands — for example the top decile as “immediate,” the next as “this year,” the remainder as “monitor.” Bands, not raw scores, are what crews and planners act on.
- Generate inspection cycles. Derive per-asset inspection due dates from install date, material interval, and criticality, worked in detail in automating inspection-cycle generation from asset age.
- Close the loop. Write each completed inspection’s grade and date back to the register so the next batch scores on observed condition, not age alone.
The batch scoring step is where the discipline becomes production automation rather than a spreadsheet. Vectorize the score over the register, band it, and emit a structured, sorted result a planner or a pipeline can act on:
import pandas as pd
def band_register(scored: pd.DataFrame, risk_col: str = "risk_score") -> pd.DataFrame:
"""Assign an intervention band to each asset from its risk score.
Expects a DataFrame already carrying `risk_col`. Returns a new frame
sorted by risk descending with an added `band` column.
"""
if risk_col not in scored.columns:
raise KeyError(f"missing required column {risk_col!r}")
ranked = scored.sort_values(risk_col, ascending=False).reset_index(drop=True)
n = len(ranked)
if n == 0:
logging.warning("empty register; nothing to band")
ranked["band"] = pd.Series(dtype="object")
return ranked
# Percentile cut points: top 10% immediate, next 20% this year, rest monitor.
cutoffs = ranked[risk_col].quantile([0.90, 0.70])
def to_band(v: float) -> str:
if v >= cutoffs[0.90]:
return "immediate"
if v >= cutoffs[0.70]:
return "this_year"
return "monitor"
ranked["band"] = ranked[risk_col].apply(to_band)
logging.info("banded %d assets: %s", n, ranked["band"].value_counts().to_dict())
return ranked
if __name__ == "__main__":
demo = pd.DataFrame({"asset_id": ["A", "B", "C", "D"],
"risk_score": [820.0, 140.0, 60.0, 15.0]})
print(band_register(demo).to_string(index=False))
Percentile banding keeps the schedule bounded by crew capacity: because the bands are cut on quantiles rather than absolute thresholds, the “immediate” queue is always the worst decile of what actually exists, not an open-ended list that balloons after a bad inspection season. Where a regulatory window demands an absolute floor — every critical-facility feeder inspected within a fixed period regardless of score — layer that constraint on after banding rather than baking it into the risk formula, so the two rationales stay auditable independently.
Diagnostic Protocol
When a schedule ranks an obviously wrong asset first, or omits one that later fails, work this checklist in order. The first item is the most common root cause.
- Null or defaulted install dates. Query for
install_datethat is null or set to an import sentinel (a suspicious pile-up on 1900-01-01 or the load date). A defaulted date makes an old asset look new or a new one ancient, and it silently dominates the health index. - Out-of-domain material class. Any
materialvalue outside the service-life lookup either raises or falls to a default life, skewing every age normalization for that class. Check the domain before scoring. - Criticality that ignores the hierarchy. Confirm
customers_servedandcriticalitywere rolled up from a validated containment model. A fragmented network under-counts downstream service points, so a trunk main scores like a lateral. - Stale inspection grades. Compare the last-inspection date against today. A grade captured a decade ago is not current condition; treat inspections beyond their own cycle as expired and let age dominate rather than trusting the old grade.
- Banding on absolute thresholds. If the “immediate” queue is empty or enormous, check whether bands were cut on fixed numbers instead of quantiles. Absolute cut points drift out of calibration as the register ages.
- Silent unit or sign errors. A negative age (future install date) or a life expressed in months rather than years produces a plausible-looking but wrong index. Assert on ranges before publishing the schedule.
Performance & Scale Considerations
An enterprise register runs to hundreds of thousands of assets, so scoring must be vectorized, not looped. Compute the health index and risk product as column operations over the GeoDataFrame — a per-feature Python loop over a full water system is minutes where a vectorized pass is sub-second. Cache the criticality roll-up: it changes only when the network topology or customer assignment changes, so recompute it on a nightly cadence rather than inside every scoring run, and read it as a joined column. The same batch discipline documented for network-wide processing applies here; heavy scoring belongs in the ingestion-and-transform layer described under data ingestion pipelines for utility assets, not in an interactive session.
Run scoring against a versioned, isolated workspace or a snapshot file geodatabase so a long batch pass never holds locks on the default version during an active edit session. Keep the inspection feedback write on its own transaction: stage completed grades in a table and apply them in batches, so a mobile field sync and the nightly re-score never contend. Where the register spans multiple systems (water, gas, electric), partition by system and score each partition independently — service-life lookups and consequence models differ, and partitioning keeps each domain’s assumptions explicit rather than averaged together.
Compliance Notes
Condition-based scheduling is not only an efficiency measure; it is the evidence base for asset-management compliance. The workflow maps onto specific checkpoints rather than gesturing at them. ISO 55001 asset-management practice expects a documented, risk-based basis for maintenance decisions — the health index, the criticality roll-up, and the risk product supply exactly that, with each score traceable to its source attributes. For water systems, AWWA G400 operational and asset-management practice assumes a defensible condition-assessment and prioritization program; a reproducible, version-stamped risk ranking over a validated register is what an auditor asks to see. The lifecycle transitions the schedule drives — an asset moving from in-service to inspection-due to work-planned — are the same states modeled in lifecycle state machines for utility assets, and logging each transition is what makes the record defensible.
To remain auditable, every scoring run should persist its inputs and outputs: the register snapshot identifier, the service-life lookup version, the banding cut points, and the resulting per-asset score and band, written to a timestamped, version-controlled log. Python’s standard logging facility captures the run metadata; routing the scored register into the asset-management system gives the durable audit trail. The required audit metadata for each run is therefore: register snapshot id, scoring timestamp, service-life and criticality model versions, banding thresholds, and the actor or pipeline that executed it. Authority for the practices themselves should be drawn only from primary sources such as the AWWA standards program.